mirror of
https://github.com/JanDeDobbeleer/oh-my-posh.git
synced 2026-08-24 02:34:19 -05:00
feat: implement async timeout for segments with cache support
Co-authored-by: JanDeDobbeleer <2492783+JanDeDobbeleer@users.noreply.github.com>
This commit is contained in:
co-authored by
JanDeDobbeleer
parent
6638dfc2df
commit
46eee055cb
Vendored
+80
@@ -0,0 +1,80 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// AsyncSegmentData represents cached data for async segments
|
||||
type AsyncSegmentData struct {
|
||||
Text string `json:"text"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Duration Duration `json:"duration"`
|
||||
}
|
||||
|
||||
// AsyncSegmentCache manages async segment caching
|
||||
type AsyncSegmentCache struct {
|
||||
cache Cache
|
||||
}
|
||||
|
||||
// NewAsyncSegmentCache creates a new async segment cache
|
||||
func NewAsyncSegmentCache(cache Cache) *AsyncSegmentCache {
|
||||
return &AsyncSegmentCache{
|
||||
cache: cache,
|
||||
}
|
||||
}
|
||||
|
||||
// GetSegmentData retrieves cached segment data
|
||||
func (a *AsyncSegmentCache) GetSegmentData(segmentName, cacheKey string) (*AsyncSegmentData, bool) {
|
||||
key := fmt.Sprintf("async_segment_%s_%s", segmentName, cacheKey)
|
||||
data, found := a.cache.Get(key)
|
||||
if !found {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
var segmentData AsyncSegmentData
|
||||
if err := json.Unmarshal([]byte(data), &segmentData); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
return &segmentData, true
|
||||
}
|
||||
|
||||
// SetSegmentData stores segment data in cache
|
||||
func (a *AsyncSegmentCache) SetSegmentData(segmentName, cacheKey string, data *AsyncSegmentData) {
|
||||
key := fmt.Sprintf("async_segment_%s_%s", segmentName, cacheKey)
|
||||
jsonData, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
a.cache.Set(key, string(jsonData), data.Duration)
|
||||
}
|
||||
|
||||
// DeleteSegmentData removes cached segment data
|
||||
func (a *AsyncSegmentCache) DeleteSegmentData(segmentName, cacheKey string) {
|
||||
key := fmt.Sprintf("async_segment_%s_%s", segmentName, cacheKey)
|
||||
a.cache.Delete(key)
|
||||
}
|
||||
|
||||
// IsAsyncProcessRunning checks if an async process is currently running for a segment
|
||||
func (a *AsyncSegmentCache) IsAsyncProcessRunning(segmentName, cacheKey string) bool {
|
||||
key := fmt.Sprintf("async_process_%s_%s", segmentName, cacheKey)
|
||||
_, found := a.cache.Get(key)
|
||||
return found
|
||||
}
|
||||
|
||||
// SetAsyncProcessRunning marks an async process as running
|
||||
func (a *AsyncSegmentCache) SetAsyncProcessRunning(segmentName, cacheKey string) {
|
||||
key := fmt.Sprintf("async_process_%s_%s", segmentName, cacheKey)
|
||||
// Set a short TTL to prevent stale process markers
|
||||
a.cache.Set(key, "running", Duration("5m"))
|
||||
}
|
||||
|
||||
// ClearAsyncProcessRunning removes the async process marker
|
||||
func (a *AsyncSegmentCache) ClearAsyncProcessRunning(segmentName, cacheKey string) {
|
||||
key := fmt.Sprintf("async_process_%s_%s", segmentName, cacheKey)
|
||||
a.cache.Delete(key)
|
||||
}
|
||||
+78
-2
@@ -2,16 +2,21 @@ package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/jandedobbeleer/oh-my-posh/src/cache"
|
||||
"github.com/jandedobbeleer/oh-my-posh/src/config"
|
||||
"github.com/jandedobbeleer/oh-my-posh/src/runtime"
|
||||
"github.com/jandedobbeleer/oh-my-posh/src/shell"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// getCmd represents the get command
|
||||
var getCache = &cobra.Command{
|
||||
Use: "cache [path|clear|edit]",
|
||||
Use: "cache [path|clear|edit|refresh-segment]",
|
||||
Short: "Interact with the oh-my-posh cache",
|
||||
Long: `Interact with the oh-my-posh cache.
|
||||
|
||||
@@ -19,11 +24,13 @@ You can do the following:
|
||||
|
||||
- path: list cache path
|
||||
- clear: remove all cache values
|
||||
- edit: edit cache values`,
|
||||
- edit: edit cache values
|
||||
- refresh-segment: refresh a specific segment cache`,
|
||||
ValidArgs: []string{
|
||||
"path",
|
||||
"clear",
|
||||
"edit",
|
||||
"refresh-segment",
|
||||
},
|
||||
Args: NoArgsOrOneValidArg,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
@@ -48,10 +55,79 @@ You can do the following:
|
||||
case "edit":
|
||||
cacheFilePath := filepath.Join(cache.Path(), cache.FileName)
|
||||
exitcode = editFileWithEditor(cacheFilePath)
|
||||
case "refresh-segment":
|
||||
refreshSegmentCache(cmd)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
// refreshSegmentCache refreshes the cache for a specific segment
|
||||
func refreshSegmentCache(cmd *cobra.Command) {
|
||||
segmentName, _ := cmd.Flags().GetString("segment")
|
||||
cacheKey, _ := cmd.Flags().GetString("cache-key")
|
||||
workingDir, _ := cmd.Flags().GetString("working-dir")
|
||||
|
||||
if segmentName == "" || cacheKey == "" || workingDir == "" {
|
||||
fmt.Println("Error: segment, cache-key, and working-dir are required")
|
||||
return
|
||||
}
|
||||
|
||||
// Change to the working directory
|
||||
if err := os.Chdir(workingDir); err != nil {
|
||||
fmt.Printf("Error changing directory: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Create runtime environment
|
||||
flags := &runtime.Flags{
|
||||
SaveCache: true,
|
||||
}
|
||||
env := &runtime.Terminal{}
|
||||
env.Init(flags)
|
||||
defer env.Close()
|
||||
|
||||
// Load configuration
|
||||
cfg, _ := config.Load(configFlag, shell.GENERIC, false)
|
||||
|
||||
// Find the segment configuration
|
||||
var segment *config.Segment
|
||||
for _, block := range cfg.Blocks {
|
||||
for _, s := range block.Segments {
|
||||
if s.Name() == segmentName || (s.Alias != "" && s.Alias == segmentName) {
|
||||
segment = s
|
||||
break
|
||||
}
|
||||
}
|
||||
if segment != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if segment == nil {
|
||||
fmt.Printf("Error: segment %s not found in configuration\n", segmentName)
|
||||
return
|
||||
}
|
||||
|
||||
// Execute the segment to get fresh data
|
||||
segment.Execute(env)
|
||||
|
||||
// Cache the result
|
||||
if segment.Enabled {
|
||||
asyncCache := cache.NewAsyncSegmentCache(env.Cache())
|
||||
asyncData := &cache.AsyncSegmentData{
|
||||
Text: segment.Text(),
|
||||
Enabled: segment.Enabled,
|
||||
Timestamp: time.Now(),
|
||||
Duration: cache.Duration("5m"), // Default 5 minutes
|
||||
}
|
||||
asyncCache.SetSegmentData(segmentName, cacheKey, asyncData)
|
||||
fmt.Printf("Async cache refreshed for segment: %s\n", segmentName)
|
||||
}
|
||||
}
|
||||
|
||||
func init() {
|
||||
getCache.Flags().StringP("segment", "s", "", "segment name for refresh-segment command")
|
||||
getCache.Flags().StringP("cache-key", "k", "", "cache key for refresh-segment command")
|
||||
getCache.Flags().StringP("working-dir", "w", "", "working directory for refresh-segment command")
|
||||
RootCmd.AddCommand(getCache)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestAsyncTimeoutFromConfig(t *testing.T) {
|
||||
jsonConfig := `{
|
||||
"version": 3,
|
||||
"blocks": [
|
||||
{
|
||||
"type": "prompt",
|
||||
"segments": [
|
||||
{
|
||||
"type": "git",
|
||||
"style": "plain",
|
||||
"async_timeout": 100,
|
||||
"properties": {
|
||||
"fetch_status": true
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
// Create a temporary file for the config
|
||||
tmpFile := "/tmp/test_config.json"
|
||||
err := os.WriteFile(tmpFile, []byte(jsonConfig), 0644)
|
||||
assert.NoError(t, err)
|
||||
defer os.Remove(tmpFile)
|
||||
|
||||
// Load the config
|
||||
cfg, _ := Load(tmpFile, "generic", false)
|
||||
assert.NotNil(t, cfg)
|
||||
|
||||
// Verify the async timeout is loaded correctly
|
||||
assert.Len(t, cfg.Blocks, 1)
|
||||
assert.Len(t, cfg.Blocks[0].Segments, 1)
|
||||
|
||||
gitSegment := cfg.Blocks[0].Segments[0]
|
||||
assert.Equal(t, "git", string(gitSegment.Type))
|
||||
assert.Equal(t, 100*time.Nanosecond, gitSegment.AsyncTimeout)
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jandedobbeleer/oh-my-posh/src/cache"
|
||||
"github.com/jandedobbeleer/oh-my-posh/src/runtime/mock"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestAsyncTimeoutConfiguration(t *testing.T) {
|
||||
segment := &Segment{
|
||||
Type: "git",
|
||||
AsyncTimeout: 100 * time.Millisecond,
|
||||
}
|
||||
|
||||
assert.Equal(t, 100*time.Millisecond, segment.AsyncTimeout)
|
||||
assert.Equal(t, "git", string(segment.Type))
|
||||
}
|
||||
|
||||
func TestAsyncCacheKey(t *testing.T) {
|
||||
env := &mock.Environment{}
|
||||
env.On("Pwd").Return("/home/user/test")
|
||||
env.On("Getenv", "GIT_DIR").Return("")
|
||||
|
||||
segment := &Segment{
|
||||
Type: "git",
|
||||
env: env,
|
||||
}
|
||||
|
||||
key := segment.generateAsyncCacheKey()
|
||||
expected := "git_/home/user/test"
|
||||
assert.Equal(t, expected, key)
|
||||
}
|
||||
|
||||
func TestAsyncCache(t *testing.T) {
|
||||
cacheFile := &cache.File{}
|
||||
cacheFile.Init("/tmp/test_cache", false)
|
||||
defer cacheFile.Close()
|
||||
|
||||
asyncCache := cache.NewAsyncSegmentCache(cacheFile)
|
||||
|
||||
// Test setting and getting async data
|
||||
data := &cache.AsyncSegmentData{
|
||||
Text: "test output",
|
||||
Enabled: true,
|
||||
Timestamp: time.Now(),
|
||||
Duration: cache.Duration("5m"),
|
||||
}
|
||||
|
||||
asyncCache.SetSegmentData("git", "test_key", data)
|
||||
|
||||
retrieved, found := asyncCache.GetSegmentData("git", "test_key")
|
||||
assert.True(t, found)
|
||||
assert.Equal(t, "test output", retrieved.Text)
|
||||
assert.True(t, retrieved.Enabled)
|
||||
}
|
||||
|
||||
func TestAsyncProcessMarker(t *testing.T) {
|
||||
cacheFile := &cache.File{}
|
||||
cacheFile.Init("/tmp/test_cache", false)
|
||||
defer cacheFile.Close()
|
||||
|
||||
asyncCache := cache.NewAsyncSegmentCache(cacheFile)
|
||||
|
||||
// Test process marker
|
||||
assert.False(t, asyncCache.IsAsyncProcessRunning("git", "test_key"))
|
||||
|
||||
asyncCache.SetAsyncProcessRunning("git", "test_key")
|
||||
assert.True(t, asyncCache.IsAsyncProcessRunning("git", "test_key"))
|
||||
|
||||
asyncCache.ClearAsyncProcessRunning("git", "test_key")
|
||||
assert.False(t, asyncCache.IsAsyncProcessRunning("git", "test_key"))
|
||||
}
|
||||
+176
-1
@@ -3,6 +3,8 @@ package config
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -67,6 +69,7 @@ type Segment struct {
|
||||
MinWidth int `json:"min_width,omitempty" toml:"min_width,omitempty" yaml:"min_width,omitempty"`
|
||||
MaxWidth int `json:"max_width,omitempty" toml:"max_width,omitempty" yaml:"max_width,omitempty"`
|
||||
Timeout time.Duration `json:"timeout,omitempty" toml:"timeout,omitempty" yaml:"timeout,omitempty"`
|
||||
AsyncTimeout time.Duration `json:"async_timeout,omitempty" toml:"async_timeout,omitempty" yaml:"async_timeout,omitempty"`
|
||||
Duration time.Duration `json:"-" toml:"-" yaml:"-"`
|
||||
NameLength int `json:"-" toml:"-" yaml:"-"`
|
||||
Interactive bool `json:"interactive,omitempty" toml:"interactive,omitempty" yaml:"interactive,omitempty"`
|
||||
@@ -124,8 +127,10 @@ func (segment *Segment) Execute(env runtime.Environment) {
|
||||
return
|
||||
}
|
||||
|
||||
if segment.Timeout == 0 {
|
||||
if segment.Timeout == 0 && segment.AsyncTimeout == 0 {
|
||||
segment.Enabled = segment.writer.Enabled()
|
||||
} else if segment.AsyncTimeout > 0 {
|
||||
segment.executeWithAsyncTimeout()
|
||||
} else {
|
||||
done := make(chan bool)
|
||||
go func() {
|
||||
@@ -147,6 +152,176 @@ func (segment *Segment) Execute(env runtime.Environment) {
|
||||
}
|
||||
}
|
||||
|
||||
// executeWithAsyncTimeout executes the segment with async timeout behavior
|
||||
func (segment *Segment) executeWithAsyncTimeout() {
|
||||
// Generate a cache key for this segment
|
||||
cacheKey := segment.generateAsyncCacheKey()
|
||||
|
||||
// Get async cache instance
|
||||
asyncCache := segment.getAsyncCache()
|
||||
if asyncCache == nil {
|
||||
log.Debugf("async cache not available for segment: %s", segment.Name())
|
||||
segment.Enabled = segment.writer.Enabled()
|
||||
return
|
||||
}
|
||||
|
||||
// Check if we have cached data
|
||||
if cachedData, found := asyncCache.GetSegmentData(segment.Name(), cacheKey); found {
|
||||
// Use cached data if available
|
||||
segment.Enabled = cachedData.Enabled
|
||||
if cachedData.Enabled {
|
||||
segment.writer.SetText(cachedData.Text)
|
||||
}
|
||||
log.Debugf("using cached data for segment: %s", segment.Name())
|
||||
|
||||
// Check if we should refresh the cache (if not already running)
|
||||
if !asyncCache.IsAsyncProcessRunning(segment.Name(), cacheKey) {
|
||||
go segment.refreshAsyncCache(cacheKey, asyncCache)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// No cached data, execute with timeout
|
||||
done := make(chan bool)
|
||||
go func() {
|
||||
segment.Enabled = segment.writer.Enabled()
|
||||
done <- true
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
// Completed before async timeout, cache the result
|
||||
if segment.Enabled {
|
||||
asyncData := &cache.AsyncSegmentData{
|
||||
Text: segment.writer.Text(),
|
||||
Enabled: segment.Enabled,
|
||||
Timestamp: time.Now(),
|
||||
Duration: segment.getCacheDuration(),
|
||||
}
|
||||
asyncCache.SetSegmentData(segment.Name(), cacheKey, asyncData)
|
||||
}
|
||||
case <-time.After(segment.AsyncTimeout * time.Millisecond):
|
||||
log.Debugf("async timeout after %dms for segment: %s", segment.AsyncTimeout, segment.Name())
|
||||
|
||||
// Start async process to update cache
|
||||
go segment.refreshAsyncCache(cacheKey, asyncCache)
|
||||
|
||||
// Return without enabling segment (no cached data available)
|
||||
segment.Enabled = false
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// generateAsyncCacheKey generates a unique cache key for async segments
|
||||
func (segment *Segment) generateAsyncCacheKey() string {
|
||||
// Include working directory and relevant segment properties
|
||||
cwd := segment.env.Pwd()
|
||||
segmentType := string(segment.Type)
|
||||
|
||||
// For git segments, include the git directory
|
||||
if segmentType == "git" {
|
||||
if gitDir := segment.env.Getenv("GIT_DIR"); gitDir != "" {
|
||||
return fmt.Sprintf("%s_%s_%s", segmentType, cwd, gitDir)
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s_%s", segmentType, cwd)
|
||||
}
|
||||
|
||||
// getAsyncCache returns the async cache instance
|
||||
func (segment *Segment) getAsyncCache() *cache.AsyncSegmentCache {
|
||||
if segment.env.Cache() == nil {
|
||||
return nil
|
||||
}
|
||||
return cache.NewAsyncSegmentCache(segment.env.Cache())
|
||||
}
|
||||
|
||||
// getCacheDuration returns the cache duration for the segment
|
||||
func (segment *Segment) getCacheDuration() cache.Duration {
|
||||
if segment.Cache != nil {
|
||||
return segment.Cache.Duration
|
||||
}
|
||||
// Default cache duration for async segments (5 minutes)
|
||||
return cache.Duration("5m")
|
||||
}
|
||||
|
||||
// refreshAsyncCache refreshes the cache in the background
|
||||
func (segment *Segment) refreshAsyncCache(cacheKey string, asyncCache *cache.AsyncSegmentCache) {
|
||||
segmentName := segment.Name()
|
||||
|
||||
// Mark as running to prevent multiple concurrent refreshes
|
||||
asyncCache.SetAsyncProcessRunning(segmentName, cacheKey)
|
||||
defer asyncCache.ClearAsyncProcessRunning(segmentName, cacheKey)
|
||||
|
||||
log.Debugf("refreshing async cache for segment: %s", segmentName)
|
||||
|
||||
// Create a fresh segment instance for background execution
|
||||
// This is necessary because the original segment might be modified
|
||||
if segment.env.Flags().Debug {
|
||||
// For debug, run in the same process
|
||||
segment.executeAsyncRefresh(cacheKey, asyncCache)
|
||||
} else {
|
||||
// For production, spawn a background process
|
||||
segment.spawnAsyncRefreshProcess(cacheKey)
|
||||
}
|
||||
}
|
||||
|
||||
// executeAsyncRefresh executes the refresh in the current process
|
||||
func (segment *Segment) executeAsyncRefresh(cacheKey string, asyncCache *cache.AsyncSegmentCache) {
|
||||
// Execute the segment without timeout
|
||||
enabled := segment.writer.Enabled()
|
||||
|
||||
// Cache the result
|
||||
asyncData := &cache.AsyncSegmentData{
|
||||
Text: segment.writer.Text(),
|
||||
Enabled: enabled,
|
||||
Timestamp: time.Now(),
|
||||
Duration: segment.getCacheDuration(),
|
||||
}
|
||||
|
||||
asyncCache.SetSegmentData(segment.Name(), cacheKey, asyncData)
|
||||
log.Debugf("async cache updated for segment: %s", segment.Name())
|
||||
}
|
||||
|
||||
// spawnAsyncRefreshProcess spawns a background process to refresh the cache
|
||||
func (segment *Segment) spawnAsyncRefreshProcess(cacheKey string) {
|
||||
// Get the current executable path
|
||||
execPath, err := os.Executable()
|
||||
if err != nil {
|
||||
log.Debugf("failed to get executable path for async refresh: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Prepare command arguments for async cache refresh
|
||||
args := []string{
|
||||
"cache", "refresh-segment",
|
||||
"--segment", segment.Name(),
|
||||
"--cache-key", cacheKey,
|
||||
"--working-dir", segment.env.Pwd(),
|
||||
}
|
||||
|
||||
// Add segment-specific properties
|
||||
if segment.Type == "git" {
|
||||
args = append(args, "--segment-type", "git")
|
||||
}
|
||||
|
||||
// Start background process
|
||||
cmd := exec.Command(execPath, args...)
|
||||
cmd.Dir = segment.env.Pwd()
|
||||
|
||||
// Set environment variables
|
||||
cmd.Env = os.Environ()
|
||||
|
||||
// Start the process without waiting for it to complete
|
||||
err = cmd.Start()
|
||||
if err != nil {
|
||||
log.Debugf("failed to start async refresh process: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Debugf("started async refresh process for segment: %s", segment.Name())
|
||||
}
|
||||
|
||||
func (segment *Segment) Render(index int, force bool) bool {
|
||||
if !segment.Enabled && !force {
|
||||
return false
|
||||
|
||||
Reference in New Issue
Block a user