diff --git a/src/config/segment.go b/src/config/segment.go index 60cabdb0ccb1..dd980cee0cd9 100644 --- a/src/config/segment.go +++ b/src/config/segment.go @@ -311,25 +311,31 @@ func (segment *Segment) restoreCache() bool { } key, store := segment.cacheKeyAndStore() - data, OK := cache.Get[string](store, key) + + data, OK := cache.Get[any](store, key) if !OK { log.Debugf("no cache found for segment: %s, key: %s", segment.Name(), key) return false } - err := json.Unmarshal([]byte(data), &segment.writer) - if err != nil { - log.Error(err) + switch v := data.(type) { + case SegmentWriter: + segment.writer = v + segment.Enabled = true + template.Cache.AddSegmentData(segment.Name(), segment.writer) + log.Debug("restored segment from cache: ", segment.Name()) + segment.restored = true + return true + case string: + // legacy JSON cache entry, remove it so it gets re-cached with the new format + log.Debugf("removing legacy cache key: %s", key) + cache.Delete(store, key) + return false + default: + log.Debugf("unexpected cache type for segment: %s, key: %s", segment.Name(), key) + cache.Delete(store, key) + return false } - - segment.Enabled = true - template.Cache.AddSegmentData(segment.Name(), segment.writer) - - log.Debug("restored segment from cache: ", segment.Name()) - - segment.restored = true - - return true } func (segment *Segment) setCache() { @@ -342,17 +348,11 @@ func (segment *Segment) setCache() { return } - data, err := json.Marshal(segment.writer) - if err != nil { - log.Error(err) - return - } - // TODO: check if we can make segmentwriter a generic Type indicator // that way we can actually get the value straight from cache.Get // and marchalling is obsolete key, store := segment.cacheKeyAndStore() - cache.Set(store, key, string(data), segment.Cache.Duration) + cache.Set(store, key, segment.writer, segment.Cache.Duration) } func (segment *Segment) cacheKeyAndStore() (string, cache.Store) { diff --git a/src/config/segment_cache_test.go b/src/config/segment_cache_test.go new file mode 100644 index 000000000000..d6de9e8e78e0 --- /dev/null +++ b/src/config/segment_cache_test.go @@ -0,0 +1,93 @@ +package config + +import ( + "testing" + + "github.com/jandedobbeleer/oh-my-posh/src/cache" + "github.com/jandedobbeleer/oh-my-posh/src/maps" + "github.com/jandedobbeleer/oh-my-posh/src/runtime/mock" + "github.com/jandedobbeleer/oh-my-posh/src/segments" + "github.com/jandedobbeleer/oh-my-posh/src/template" + "github.com/stretchr/testify/assert" +) + +func TestSegmentCache(t *testing.T) { + template.Cache = &cache.Template{ + Segments: maps.NewConcurrent[any](), + } + defer cache.DeleteAll(cache.Device) + + env := new(mock.Environment) + env.On("Pwd").Return("/tmp") + + segment := &Segment{ + Type: TEXT, + Cache: &Cache{ + Strategy: Folder, + Duration: cache.Duration("10m"), + }, + Alias: "my_text_segment", + env: env, + } + + textWriter := &segments.Text{} + textWriter.Init(nil, nil) + textWriter.SetText("Hello, Cache!") + segment.writer = textWriter + segment.name = "my_text_segment" + + segment.setCache() + + newSegment := &Segment{ + Type: TEXT, + Cache: &Cache{ + Strategy: Folder, + Duration: cache.Duration("10m"), + }, + Alias: "my_text_segment", + env: env, + } + newSegment.name = "my_text_segment" + + newTextWriter := &segments.Text{} + newTextWriter.Init(nil, nil) + newSegment.writer = newTextWriter + + restored := newSegment.restoreCache() + + assert.True(t, restored, "Cache should be restored") + assert.NotNil(t, newSegment.writer, "Writer should be restored") + assert.IsType(t, &segments.Text{}, newSegment.writer, "Writer should be of type *segments.Text") + if newSegment.writer != nil { + assert.Equal(t, "Hello, Cache!", newSegment.writer.Text(), "Restored text should match") + } + + // Test legacy cache (string value) + legacySegment := &Segment{ + Type: TEXT, + Cache: &Cache{ + Strategy: Device, + Duration: cache.Duration("10m"), + }, + Alias: "legacy_segment", + env: env, + } + legacySegment.name = "legacy_segment" + + // Initialize writer so cacheKeyAndStore can compute the key + legacyWriter := &segments.Text{} + legacyWriter.Init(nil, nil) + legacySegment.writer = legacyWriter + + // Derive the key using the same logic as production code + legacyKey, legacyStore := legacySegment.cacheKeyAndStore() + cache.Set(legacyStore, legacyKey, "legacy_json_string", cache.Duration("10m")) + + // Should return false and delete the key + restoredLegacy := legacySegment.restoreCache() + assert.False(t, restoredLegacy, "Legacy cache should not be restored") + + // Verify key is gone + _, found := cache.Get[string](legacyStore, legacyKey) + assert.False(t, found, "Legacy key should be removed") +} diff --git a/src/config/segment_types.go b/src/config/segment_types.go index 7ac77d10c815..0ec281e2cd54 100644 --- a/src/config/segment_types.go +++ b/src/config/segment_types.go @@ -127,6 +127,7 @@ func init() { gob.Register(&segments.Swift{}) gob.Register(&segments.SystemInfo{}) gob.Register(&segments.TalosCTL{}) + gob.Register(&segments.Taskwarrior{}) gob.Register(&segments.Tauri{}) gob.Register(&segments.Terraform{}) gob.Register(&segments.Text{}) @@ -338,6 +339,8 @@ const ( SYSTEMINFO SegmentType = "sysinfo" // TALOSCTL writes the talosctl context TALOSCTL SegmentType = "talosctl" + // TASKWARRIOR writes Taskwarrior task counts and context + TASKWARRIOR SegmentType = "taskwarrior" // Tauri Segment TAURI SegmentType = "tauri" // TERRAFORM writes the terraform workspace we're currently in @@ -470,6 +473,7 @@ var Segments = map[SegmentType]func() SegmentWriter{ SWIFT: func() SegmentWriter { return &segments.Swift{} }, SYSTEMINFO: func() SegmentWriter { return &segments.SystemInfo{} }, TALOSCTL: func() SegmentWriter { return &segments.TalosCTL{} }, + TASKWARRIOR: func() SegmentWriter { return &segments.Taskwarrior{} }, TAURI: func() SegmentWriter { return &segments.Tauri{} }, TERRAFORM: func() SegmentWriter { return &segments.Terraform{} }, TEXT: func() SegmentWriter { return &segments.Text{} }, diff --git a/src/segments/executiontime.go b/src/segments/executiontime.go index 6497714f83f9..c8edcdc4305e 100644 --- a/src/segments/executiontime.go +++ b/src/segments/executiontime.go @@ -40,6 +40,10 @@ const ( Round DurationStyle = "round" // Always 7 character width Lucky7 = "lucky7" + // ISO8601 ISO 8601 duration format (seconds) + ISO8601 DurationStyle = "iso8601" + // ISO8601Ms ISO 8601 duration format with milliseconds + ISO8601Ms DurationStyle = "iso8601ms" second = 1000 minute = 60000 @@ -87,6 +91,10 @@ func (t *Executiontime) formatDuration(style DurationStyle) string { return t.formatDurationRound() case Lucky7: return t.formatDurationLucky7() + case ISO8601: + return t.formatDurationISO8601() + case ISO8601Ms: + return t.formatDurationISO8601Ms() default: return fmt.Sprintf("Style: %s is not available", style) } @@ -273,3 +281,63 @@ func (t *Executiontime) formatDurationLucky7() string { d := t.Ms / day return fmt.Sprintf("%6dd", d) } + +func (t *Executiontime) formatDurationISO8601() string { + // ISO 8601 duration format: PT[n]H[n]M[n]S + // Examples: PT13M12S, PT1H30M45S + result := "PT" + + hours := t.Ms / hour + minutes := (t.Ms % hour) / minute + seconds := float64(t.Ms%minute) / second + + roundedSeconds := int64(seconds) + if t.Ms%second >= second/2 { + roundedSeconds++ + } + + // Handle potential overflow from rounding + if roundedSeconds >= secondsPerMinute { + roundedSeconds = 0 + minutes++ + if minutes >= minutesPerHour { + minutes = 0 + hours++ + } + } + + if hours > 0 { + result += fmt.Sprintf("%dH", hours) + } + if minutes > 0 { + result += fmt.Sprintf("%dM", minutes) + } + if roundedSeconds > 0 || (hours == 0 && minutes == 0) { + result += fmt.Sprintf("%dS", roundedSeconds) + } + + return result +} + +func (t *Executiontime) formatDurationISO8601Ms() string { + // ISO 8601 duration format with milliseconds: PT[n]H[n]M[n]S + // Examples: PT13M12.1S, PT1H30M45.123S + result := "PT" + + hours := t.Ms / hour + minutes := (t.Ms % hour) / minute + seconds := float64(t.Ms%minute) / second + + if hours > 0 { + result += fmt.Sprintf("%dH", hours) + } + if minutes > 0 { + result += fmt.Sprintf("%dM", minutes) + } + if seconds > 0 || (hours == 0 && minutes == 0) { + secondsStr := strconv.FormatFloat(seconds, 'f', -1, 64) + result += fmt.Sprintf("%sS", secondsStr) + } + + return result +} diff --git a/src/segments/executiontime_test.go b/src/segments/executiontime_test.go index dfb3681da728..dbd225de47cf 100644 --- a/src/segments/executiontime_test.go +++ b/src/segments/executiontime_test.go @@ -362,3 +362,59 @@ func TestExecutionTimeFormatDurationLucky7(t *testing.T) { assert.Equal(t, len(executionTime), 7) } } + +func TestExecutionTimeFormatISO8601(t *testing.T) { + cases := []struct { + Input string + Expected string + }{ + {Input: "0.001s", Expected: "PT0S"}, + {Input: "0.1s", Expected: "PT0S"}, + {Input: "0.5s", Expected: "PT1S"}, + {Input: "1s", Expected: "PT1S"}, + {Input: "2.1s", Expected: "PT2S"}, + {Input: "2.6s", Expected: "PT3S"}, + {Input: "1m", Expected: "PT1M"}, + {Input: "3m2.1s", Expected: "PT3M2S"}, + {Input: "3m2.6s", Expected: "PT3M3S"}, + {Input: "1h", Expected: "PT1H"}, + {Input: "4h3m2.1s", Expected: "PT4H3M2S"}, + {Input: "124h3m2.1s", Expected: "PT124H3M2S"}, + {Input: "124h3m2.0s", Expected: "PT124H3M2S"}, + } + + for _, tc := range cases { + duration, _ := time.ParseDuration(tc.Input) + executionTime := &Executiontime{} + executionTime.Ms = duration.Milliseconds() + output := executionTime.formatDurationISO8601() + assert.Equal(t, tc.Expected, output, "Input: %s", tc.Input) + } +} + +func TestExecutionTimeFormatISO8601Ms(t *testing.T) { + cases := []struct { + Input string + Expected string + }{ + {Input: "0.001s", Expected: "PT0.001S"}, + {Input: "0.1s", Expected: "PT0.1S"}, + {Input: "1s", Expected: "PT1S"}, + {Input: "2.1s", Expected: "PT2.1S"}, + {Input: "2.123s", Expected: "PT2.123S"}, + {Input: "1m", Expected: "PT1M"}, + {Input: "3m2.1s", Expected: "PT3M2.1S"}, + {Input: "3m2.123s", Expected: "PT3M2.123S"}, + {Input: "1h", Expected: "PT1H"}, + {Input: "4h3m2.1s", Expected: "PT4H3M2.1S"}, + {Input: "124h3m2.123s", Expected: "PT124H3M2.123S"}, + } + + for _, tc := range cases { + duration, _ := time.ParseDuration(tc.Input) + executionTime := &Executiontime{} + executionTime.Ms = duration.Milliseconds() + output := executionTime.formatDurationISO8601Ms() + assert.Equal(t, tc.Expected, output, "Input: %s", tc.Input) + } +} diff --git a/src/segments/taskwarrior.go b/src/segments/taskwarrior.go new file mode 100644 index 000000000000..dadc077851ed --- /dev/null +++ b/src/segments/taskwarrior.go @@ -0,0 +1,69 @@ +package segments + +import ( + "strings" + + c "golang.org/x/text/cases" + "golang.org/x/text/language" + + "github.com/jandedobbeleer/oh-my-posh/src/log" + "github.com/jandedobbeleer/oh-my-posh/src/segments/options" +) + +// Taskwarrior option constants +const ( + TaskwarriorCommand options.Option = "command" + TaskwarriorCommands options.Option = "commands" +) + +// Taskwarrior displays task counts and context from Taskwarrior. +// The Commands field is a map from capitalized command name to the raw output +// of the corresponding Taskwarrior invocation. Each entry in the config map +// has the command name as key and a full Taskwarrior argument string as value. +type Taskwarrior struct { + Base + + // Commands holds the raw output of each configured command, keyed by name + // with the first letter uppercased. + Commands map[string]string +} + +func (t *Taskwarrior) Template() string { + return " \uf4a0 {{ range $k, $v := .Commands }}{{ $k }}:{{ $v }} {{ end }}" +} + +func (t *Taskwarrior) Enabled() bool { + cmd := t.options.String(TaskwarriorCommand, "task") + + if !t.env.HasCommand(cmd) { + return false + } + + defaultCommands := map[string]string{ + "due": "+PENDING due.before:tomorrow count", + "scheduled": "+PENDING scheduled.before:tomorrow count", + "waiting": "+WAITING count", + "context": "_get rc.context", + } + + configuredCommands := t.options.KeyValueMap(TaskwarriorCommands, defaultCommands) + + t.Commands = make(map[string]string, len(configuredCommands)) + + for name, args := range configuredCommands { + key := c.Title(language.English).String(name) + t.Commands[key] = t.runCommand(cmd, args) + } + + return true +} + +func (t *Taskwarrior) runCommand(cmd, args string) string { + output, err := t.env.RunCommand(cmd, strings.Fields(args)...) + if err != nil { + log.Error(err) + return "" + } + + return strings.TrimSpace(output) +} diff --git a/src/segments/taskwarrior_test.go b/src/segments/taskwarrior_test.go new file mode 100644 index 000000000000..edd58e83ea69 --- /dev/null +++ b/src/segments/taskwarrior_test.go @@ -0,0 +1,184 @@ +package segments + +import ( + "errors" + "strings" + "testing" + + "github.com/jandedobbeleer/oh-my-posh/src/runtime/mock" + "github.com/jandedobbeleer/oh-my-posh/src/segments/options" + + "github.com/stretchr/testify/assert" +) + +func TestTaskwarrior(t *testing.T) { + cases := []struct { + ConfiguredCommands map[string]string + CommandOutputs map[string]string + CommandErrors map[string]error + ExpectedCommands map[string]string + Case string + Command string + HasCommand bool + ExpectedEnabled bool + }{ + { + Case: "happy path default commands", + HasCommand: true, + CommandOutputs: map[string]string{ + "+PENDING due.before:tomorrow count": "3", + "+PENDING scheduled.before:tomorrow count": "1", + "+WAITING count": "2", + }, + ExpectedEnabled: true, + ExpectedCommands: map[string]string{ + "Due": "3", + "Scheduled": "1", + "Waiting": "2", + "Context": "", + }, + }, + { + Case: "no command", + HasCommand: false, + ExpectedEnabled: false, + }, + { + Case: "all zeros", + HasCommand: true, + CommandOutputs: map[string]string{ + "+PENDING due.before:tomorrow count": "0", + "+PENDING scheduled.before:tomorrow count": "0", + "+WAITING count": "0", + }, + ExpectedEnabled: true, + ExpectedCommands: map[string]string{ + "Due": "0", + "Scheduled": "0", + "Waiting": "0", + "Context": "", + }, + }, + { + Case: "custom commands only", + HasCommand: true, + ConfiguredCommands: map[string]string{ + "urgent": "+PENDING +OVERDUE count", + }, + CommandOutputs: map[string]string{ + "+PENDING +OVERDUE count": "5", + }, + ExpectedEnabled: true, + ExpectedCommands: map[string]string{ + "Urgent": "5", + }, + }, + { + Case: "context command via commands map", + HasCommand: true, + ConfiguredCommands: map[string]string{ + "due": "+PENDING due.before:tomorrow count", + "context": "_get rc.context", + }, + CommandOutputs: map[string]string{ + "+PENDING due.before:tomorrow count": "3", + "_get rc.context": "work", + }, + ExpectedEnabled: true, + ExpectedCommands: map[string]string{ + "Due": "3", + "Context": "work", + }, + }, + { + Case: "command error returns empty string for that command", + HasCommand: true, + ConfiguredCommands: map[string]string{ + "due": "+PENDING due.before:tomorrow count", + }, + CommandErrors: map[string]error{ + "+PENDING due.before:tomorrow count": errors.New("command failed"), + }, + ExpectedEnabled: true, + ExpectedCommands: map[string]string{ + "Due": "", + }, + }, + { + Case: "custom executable is used when TaskwarriorCommand is set", + Command: "task2", + ConfiguredCommands: map[string]string{ + "due": "+PENDING due.before:tomorrow count", + }, + CommandOutputs: map[string]string{ + "+PENDING due.before:tomorrow count": "7", + }, + HasCommand: true, + ExpectedEnabled: true, + ExpectedCommands: map[string]string{ + "Due": "7", + }, + }, + } + + for _, tc := range cases { + t.Run(tc.Case, func(t *testing.T) { + env := new(mock.Environment) + + cmd := tc.Command + if cmd == "" { + cmd = "task" + } + + env.On("HasCommand", cmd).Return(tc.HasCommand) + + configuredCommands := tc.ConfiguredCommands + if configuredCommands == nil && tc.HasCommand { + // default commands + configuredCommands = map[string]string{ + "due": "+PENDING due.before:tomorrow count", + "scheduled": "+PENDING scheduled.before:tomorrow count", + "waiting": "+WAITING count", + "context": "_get rc.context", + } + } + + for _, args := range configuredCommands { + splitArgs := splitTaskArgs(args) + var output string + var err error + if tc.CommandOutputs != nil { + output = tc.CommandOutputs[args] + } + if tc.CommandErrors != nil { + err = tc.CommandErrors[args] + } + env.On("RunCommand", cmd, splitArgs).Return(output, err) + } + + props := options.Map{} + if tc.ConfiguredCommands != nil { + props[TaskwarriorCommands] = tc.ConfiguredCommands + } + if tc.Command != "" { + props[TaskwarriorCommand] = tc.Command + } + + tw := &Taskwarrior{} + tw.Init(props, env) + + assert.Equal(t, tc.ExpectedEnabled, tw.Enabled(), tc.Case) + + if !tc.ExpectedEnabled { + return + } + + assert.Equal(t, tc.ExpectedCommands, tw.Commands, tc.Case) + }) + } +} + +// splitTaskArgs splits a space-separated argument string into a slice. +func splitTaskArgs(s string) []string { + return strings.Fields(s) +} diff --git a/themes/schema.json b/themes/schema.json index 8343451c8a0d..603b61144ab1 100644 --- a/themes/schema.json +++ b/themes/schema.json @@ -454,6 +454,7 @@ "swift", "sysinfo", "talosctl", + "taskwarrior", "tauri", "terraform", "text", @@ -4476,6 +4477,46 @@ } } }, + { + "if": { + "properties": { + "type": { + "const": "taskwarrior" + } + } + }, + "then": { + "title": "Taskwarrior Segment", + "description": "https://ohmyposh.dev/docs/segments/cli/taskwarrior", + "properties": { + "options": { + "properties": { + "command": { + "type": "string", + "title": "Taskwarrior Command", + "description": "The taskwarrior command to use", + "default": "task" + }, + "commands": { + "type": "object", + "title": "Commands", + "description": "Map of name to Taskwarrior arguments; the trimmed stdout of each invocation is exposed as .Commands. in the template", + "additionalProperties": { + "type": "string" + }, + "default": { + "due": "+PENDING due.before:tomorrow count", + "scheduled": "+PENDING scheduled.before:tomorrow count", + "waiting": "+WAITING count", + "context": "_get rc.context" + } + } + }, + "unevaluatedProperties": false + } + } + } + }, { "if": { "properties": { diff --git a/website/docs/segments/cli/taskwarrior.mdx b/website/docs/segments/cli/taskwarrior.mdx new file mode 100644 index 000000000000..333e86e749aa --- /dev/null +++ b/website/docs/segments/cli/taskwarrior.mdx @@ -0,0 +1,93 @@ +--- +id: taskwarrior +title: Taskwarrior +sidebar_label: Taskwarrior +--- + +## What + +Display [Taskwarrior][taskwarrior] task data for configurable commands. Each named command runs +`task` with the specified arguments and exposes the raw output in the template. + +## Sample Configuration + +import Config from "@site/src/components/Config.js"; + + + +## Options + +| Name | Type | Default | Description | +| ---------- | :-----------------: | :-------: | ------------------------------------------------------------------------------ | +| `command` | `string` | `task` | the Taskwarrior executable to use | +| `commands` | `map[string]string` | see below | map of name to Taskwarrior arguments; the raw output is exposed in `.Commands` | + +### Default `commands` value + +```json +{ + "due": "+PENDING due.before:tomorrow count", + "scheduled": "+PENDING scheduled.before:tomorrow count", + "waiting": "+WAITING count", + "context": "_get rc.context" +} +``` + +Each entry runs `task ` and stores the trimmed stdout as a string. Remove entries +you do not need to keep prompt rendering fast. + +## Template ([info][templates]) + +:::note default template + +```template + {{ "\uf4a0" }} {{ range $k, $v := .Commands }}{{ $k }}:{{ $v }} {{ end }} +``` + +::: + +### Properties + +| Name | Type | Description | +| ------------ | ------------------- | ------------------------------------------------------------------------------------ | +| `.Commands` | `map[string]string` | raw command output keyed by name with the first letter uppercased (e.g. `"Due"`) | + +### Examples + +Access a specific command result directly: + +```template + {{ "\uf4a0" }} Due: {{ .Commands.Due }} +``` + +Display multiple results: + +```template + {{ "\uf4a0" }} Due: {{ .Commands.Due }} | Waiting: {{ .Commands.Waiting }} +``` + +Include the active context alongside task counts: + +```template + {{ "\uf4a0" }} {{ .Commands.Context }} - Due: {{ .Commands.Due }} Scheduled: {{ .Commands.Scheduled }} +``` + +[templates]: /docs/configuration/templates +[taskwarrior]: https://taskwarrior.org diff --git a/website/docs/segments/system/executiontime.mdx b/website/docs/segments/system/executiontime.mdx index 2afca7a2f08f..9f37bcb9b5c3 100644 --- a/website/docs/segments/system/executiontime.mdx +++ b/website/docs/segments/system/executiontime.mdx @@ -51,6 +51,9 @@ Style specifies the format in which the time will be displayed. The table below | `amarillo` | `0.001s` | `2.1s` | `182.1s` | `14,582.1s` | | `round` | `1ms` | `2s` | `3m 2s` | `4h 3m` | | `lucky7` | `    1ms` | ` 2.00s ` | ` 3m  2s` | ` 4h  3m` | +| `iso8601` | `PT0S` | `PT2S` | `PT3M2S` | `PT4H3M2S` | +| `iso8601ms` | `PT0.001S` | `PT2.1S` | `PT3M2.1S` | `PT4H3M2.1S` | + ## Template ([info][templates]) diff --git a/website/sidebars.js b/website/sidebars.js index ad5082004c4c..508de6ec42a2 100644 --- a/website/sidebars.js +++ b/website/sidebars.js @@ -79,6 +79,7 @@ export default { "segments/cli/react", "segments/cli/svelte", "segments/cli/talosctl", + "segments/cli/taskwarrior", "segments/cli/tauri", "segments/cli/terraform", "segments/cli/ui5tooling",