diff --git a/internal/config/load.go b/internal/config/load.go index b644eb3f2b35253c310dd899dbb06fcfe65e6b2e..a703a049c7697be9209d3994c857ff0548f60b8b 100644 --- a/internal/config/load.go +++ b/internal/config/load.go @@ -16,6 +16,7 @@ import ( "github.com/charmbracelet/crush/internal/csync" "github.com/charmbracelet/crush/internal/env" "github.com/charmbracelet/crush/internal/fsext" + "github.com/charmbracelet/crush/internal/home" "github.com/charmbracelet/crush/internal/log" ) @@ -584,7 +585,7 @@ func globalConfig() string { return filepath.Join(localAppData, appName, fmt.Sprintf("%s.json", appName)) } - return filepath.Join(os.Getenv("HOME"), ".config", appName, fmt.Sprintf("%s.json", appName)) + return filepath.Join(home.Dir(), ".config", appName, fmt.Sprintf("%s.json", appName)) } // GlobalConfigData returns the path to the main data directory for the application. @@ -606,5 +607,5 @@ func GlobalConfigData() string { return filepath.Join(localAppData, appName, fmt.Sprintf("%s.json", appName)) } - return filepath.Join(os.Getenv("HOME"), ".local", "share", appName, fmt.Sprintf("%s.json", appName)) + return filepath.Join(home.Dir(), ".local", "share", appName, fmt.Sprintf("%s.json", appName)) } diff --git a/internal/config/provider.go b/internal/config/provider.go index 56125d482286b7c954af2254f185311ec142df04..68ede5095506b21dc4d744e309aaa836917345e5 100644 --- a/internal/config/provider.go +++ b/internal/config/provider.go @@ -12,6 +12,7 @@ import ( "time" "github.com/charmbracelet/catwalk/pkg/catwalk" + "github.com/charmbracelet/crush/internal/home" ) type ProviderClient interface { @@ -41,7 +42,7 @@ func providerCacheFileData() string { return filepath.Join(localAppData, appName, "providers.json") } - return filepath.Join(os.Getenv("HOME"), ".local", "share", appName, "providers.json") + return filepath.Join(home.Dir(), ".local", "share", appName, "providers.json") } func saveProvidersInCache(path string, providers []catwalk.Provider) error { diff --git a/internal/fsext/fileutil.go b/internal/fsext/fileutil.go index e68888452cdc190cb1e6cbdec8d87760dd8e432c..ee5fff66fb66e152319ea40c6abab4950a276a2f 100644 --- a/internal/fsext/fileutil.go +++ b/internal/fsext/fileutil.go @@ -10,6 +10,7 @@ import ( "github.com/bmatcuk/doublestar/v4" "github.com/charlievieth/fastwalk" + "github.com/charmbracelet/crush/internal/home" ignore "github.com/sabhiram/go-gitignore" ) @@ -182,12 +183,7 @@ func GlobWithDoubleStar(pattern, searchPath string, limit int) ([]string, bool, } func PrettyPath(path string) string { - // replace home directory with ~ - homeDir, err := os.UserHomeDir() - if err == nil { - path = strings.ReplaceAll(path, homeDir, "~") - } - return path + return home.Short(path) } func DirTrim(pwd string, lim int) string { diff --git a/internal/fsext/home.go b/internal/fsext/home.go deleted file mode 100644 index d81a4bc251c0205032a606e91bc53eac9bd43918..0000000000000000000000000000000000000000 --- a/internal/fsext/home.go +++ /dev/null @@ -1,20 +0,0 @@ -package fsext - -import ( - "cmp" - "os" - "os/user" - "sync" -) - -var HomeDir = sync.OnceValue(func() string { - u, err := user.Current() - if err == nil { - return u.HomeDir - } - return cmp.Or( - os.Getenv("HOME"), - os.Getenv("USERPROFILE"), - os.Getenv("HOMEPATH"), - ) -}) diff --git a/internal/fsext/ls.go b/internal/fsext/ls.go index e4b98bb2810d7e5014b881f3b0cac51f4e71965c..884c5b150e64cce3da3d1e3f2e08355a53361272 100644 --- a/internal/fsext/ls.go +++ b/internal/fsext/ls.go @@ -9,6 +9,7 @@ import ( "github.com/charlievieth/fastwalk" "github.com/charmbracelet/crush/internal/csync" + "github.com/charmbracelet/crush/internal/home" ignore "github.com/sabhiram/go-gitignore" ) @@ -73,7 +74,7 @@ var commonIgnorePatterns = sync.OnceValue(func() ignore.IgnoreParser { }) var homeIgnore = sync.OnceValue(func() ignore.IgnoreParser { - home := HomeDir() + home := home.Dir() var lines []string for _, name := range []string{ filepath.Join(home, ".gitignore"), diff --git a/internal/fsext/parent.go b/internal/fsext/parent.go index 1b04143660e7700c51693ededf90ef7489a10e18..bd3193610a79cbc80b5bb2c1d75be32a819f34f5 100644 --- a/internal/fsext/parent.go +++ b/internal/fsext/parent.go @@ -4,6 +4,8 @@ import ( "errors" "os" "path/filepath" + + "github.com/charmbracelet/crush/internal/home" ) // SearchParent searches for a target file or directory starting from dir @@ -33,7 +35,7 @@ func SearchParent(dir, target string) (string, bool) { for { parent := filepath.Dir(previousParent) - if parent == previousParent || parent == HomeDir() { + if parent == previousParent || parent == home.Dir() { return "", false } diff --git a/internal/home/home.go b/internal/home/home.go new file mode 100644 index 0000000000000000000000000000000000000000..f2a9b73b922abd8f027ba68655afc68f42a58b09 --- /dev/null +++ b/internal/home/home.go @@ -0,0 +1,42 @@ +package home + +import ( + "log/slog" + "os" + "path/filepath" + "strings" + "sync" +) + +// Dir returns the users home directory, or if it fails, tries to create a new +// temporary directory and use that instead. +var Dir = sync.OnceValue(func() string { + home, err := os.UserHomeDir() + if err == nil { + slog.Debug("user home directory", "home", home) + return home + } + tmp, err := os.MkdirTemp("crush", "") + if err != nil { + slog.Error("could not find the user home directory") + return "" + } + slog.Warn("could not find the user home directory, using a temporary one", "home", tmp) + return tmp +}) + +// Short replaces the actual home path from [Dir] with `~`. +func Short(p string) string { + if !strings.HasPrefix(p, Dir()) || Dir() == "" { + return p + } + return filepath.Join("~", strings.TrimPrefix(p, Dir())) +} + +// Long replaces the `~` with actual home path from [Dir]. +func Long(p string) string { + if !strings.HasPrefix(p, "~") || Dir() == "" { + return p + } + return strings.Replace(p, "~", Dir(), 1) +} diff --git a/internal/home/home_test.go b/internal/home/home_test.go new file mode 100644 index 0000000000000000000000000000000000000000..e5775c31bd51545b4d9f6ec5dbd9f28cac69ae16 --- /dev/null +++ b/internal/home/home_test.go @@ -0,0 +1,26 @@ +package home + +import ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestDir(t *testing.T) { + require.NotEmpty(t, Dir()) +} + +func TestShort(t *testing.T) { + d := filepath.Join(Dir(), "documents", "file.txt") + require.Equal(t, filepath.FromSlash("~/documents/file.txt"), Short(d)) + ad := filepath.FromSlash("/absolute/path/file.txt") + require.Equal(t, ad, Short(ad)) +} + +func TestLong(t *testing.T) { + d := filepath.FromSlash("~/documents/file.txt") + require.Equal(t, filepath.Join(Dir(), "documents", "file.txt"), Long(d)) + ad := filepath.FromSlash("/absolute/path/file.txt") + require.Equal(t, ad, Long(ad)) +} diff --git a/internal/llm/prompt/prompt.go b/internal/llm/prompt/prompt.go index 8c87482a71679f5bc682e6fdd8c1f5a03b89c184..919686a7d248d6ac2f02ae21ff4a323b26fc536f 100644 --- a/internal/llm/prompt/prompt.go +++ b/internal/llm/prompt/prompt.go @@ -9,6 +9,7 @@ import ( "github.com/charmbracelet/crush/internal/config" "github.com/charmbracelet/crush/internal/csync" "github.com/charmbracelet/crush/internal/env" + "github.com/charmbracelet/crush/internal/home" ) type PromptID string @@ -44,18 +45,7 @@ func getContextFromPaths(workingDir string, contextPaths []string) string { // expandPath expands ~ and environment variables in file paths func expandPath(path string) string { - // Handle tilde expansion - if strings.HasPrefix(path, "~/") { - homeDir, err := os.UserHomeDir() - if err == nil { - path = filepath.Join(homeDir, path[2:]) - } - } else if path == "~" { - homeDir, err := os.UserHomeDir() - if err == nil { - path = homeDir - } - } + path = home.Long(path) // Handle environment variable expansion using the same pattern as config if strings.HasPrefix(path, "$") { diff --git a/internal/llm/prompt/prompt_test.go b/internal/llm/prompt/prompt_test.go index e4289595fa13b4d5a9e4ef12302b2390edcdba54..66f9d438d9a5ab62d0f0871c718b166ad44795b0 100644 --- a/internal/llm/prompt/prompt_test.go +++ b/internal/llm/prompt/prompt_test.go @@ -2,10 +2,10 @@ package prompt import ( "os" - "path/filepath" - "runtime" "strings" "testing" + + "github.com/charmbracelet/crush/internal/home" ) func TestExpandPath(t *testing.T) { @@ -25,16 +25,14 @@ func TestExpandPath(t *testing.T) { name: "tilde expansion", input: "~/documents", expected: func() string { - home, _ := os.UserHomeDir() - return filepath.Join(home, "documents") + return home.Dir() + "/documents" }, }, { name: "tilde only", input: "~", expected: func() string { - home, _ := os.UserHomeDir() - return home + return home.Dir() }, }, { @@ -69,55 +67,3 @@ func TestExpandPath(t *testing.T) { }) } } - -func TestProcessContextPaths(t *testing.T) { - // Create a temporary directory and file for testing - tmpDir := t.TempDir() - testFile := filepath.Join(tmpDir, "test.txt") - testContent := "test content" - - err := os.WriteFile(testFile, []byte(testContent), 0o644) - if err != nil { - t.Fatalf("Failed to create test file: %v", err) - } - - // Test with absolute path to file - result := processContextPaths("", []string{testFile}) - expected := "# From:" + testFile + "\n" + testContent - - if result != expected { - t.Errorf("processContextPaths with absolute path failed.\nGot: %q\nWant: %q", result, expected) - } - - // Test with directory path (should process all files in directory) - result = processContextPaths("", []string{tmpDir}) - if !strings.Contains(result, testContent) { - t.Errorf("processContextPaths with directory path failed to include file content") - } - - // Test with tilde expansion (if we can create a file in home directory) - tmpDir = t.TempDir() - setHomeEnv(t, tmpDir) - homeTestFile := filepath.Join(tmpDir, "crush_test_file.txt") - err = os.WriteFile(homeTestFile, []byte(testContent), 0o644) - if err == nil { - defer os.Remove(homeTestFile) // Clean up - - tildeFile := "~/crush_test_file.txt" - result = processContextPaths("", []string{tildeFile}) - expected = "# From:" + homeTestFile + "\n" + testContent - - if result != expected { - t.Errorf("processContextPaths with tilde expansion failed.\nGot: %q\nWant: %q", result, expected) - } - } -} - -func setHomeEnv(tb testing.TB, path string) { - tb.Helper() - key := "HOME" - if runtime.GOOS == "windows" { - key = "USERPROFILE" - } - tb.Setenv(key, path) -} diff --git a/internal/tui/components/chat/sidebar/sidebar.go b/internal/tui/components/chat/sidebar/sidebar.go index eeabeac3f9a7b1f17c2b24acc4950deb186ff56b..236c5d2e31c6e7f81482757ff750f572e23cc3fb 100644 --- a/internal/tui/components/chat/sidebar/sidebar.go +++ b/internal/tui/components/chat/sidebar/sidebar.go @@ -3,7 +3,6 @@ package sidebar import ( "context" "fmt" - "os" "slices" "strings" @@ -14,6 +13,7 @@ import ( "github.com/charmbracelet/crush/internal/diff" "github.com/charmbracelet/crush/internal/fsext" "github.com/charmbracelet/crush/internal/history" + "github.com/charmbracelet/crush/internal/home" "github.com/charmbracelet/crush/internal/lsp" "github.com/charmbracelet/crush/internal/pubsub" "github.com/charmbracelet/crush/internal/session" @@ -609,11 +609,5 @@ func (m *sidebarCmp) SetCompactMode(compact bool) { func cwd() string { cwd := config.Get().WorkingDir() t := styles.CurrentTheme() - // Replace home directory with ~, unless we're at the top level of the - // home directory). - homeDir, err := os.UserHomeDir() - if err == nil && cwd != homeDir { - cwd = strings.ReplaceAll(cwd, homeDir, "~") - } - return t.S().Muted.Render(cwd) + return t.S().Muted.Render(home.Short(cwd)) } diff --git a/internal/tui/components/chat/splash/splash.go b/internal/tui/components/chat/splash/splash.go index 2416888fa184d5dcd04e0770e0816b9ee63fd5bd..7fa46cdd279a2cbe98a86654a23e81a49bc8aebf 100644 --- a/internal/tui/components/chat/splash/splash.go +++ b/internal/tui/components/chat/splash/splash.go @@ -2,7 +2,6 @@ package splash import ( "fmt" - "os" "strings" "time" @@ -11,6 +10,7 @@ import ( tea "github.com/charmbracelet/bubbletea/v2" "github.com/charmbracelet/catwalk/pkg/catwalk" "github.com/charmbracelet/crush/internal/config" + "github.com/charmbracelet/crush/internal/home" "github.com/charmbracelet/crush/internal/llm/prompt" "github.com/charmbracelet/crush/internal/tui/components/chat" "github.com/charmbracelet/crush/internal/tui/components/core" @@ -648,12 +648,7 @@ func (s *splashCmp) cwdPart() string { } func (s *splashCmp) cwd() string { - cwd := config.Get().WorkingDir() - homeDir, err := os.UserHomeDir() - if err == nil && cwd != homeDir { - cwd = strings.ReplaceAll(cwd, homeDir, "~") - } - return cwd + return home.Short(config.Get().WorkingDir()) } func LSPList(maxWidth int) []string { diff --git a/internal/tui/components/dialogs/commands/loader.go b/internal/tui/components/dialogs/commands/loader.go index 9aee528ee48d0f23e48c417f8bee5bc0e3f381c5..74d9c7e4baee2e2d19f8baca914942f0c0d34cd3 100644 --- a/internal/tui/components/dialogs/commands/loader.go +++ b/internal/tui/components/dialogs/commands/loader.go @@ -10,6 +10,7 @@ import ( tea "github.com/charmbracelet/bubbletea/v2" "github.com/charmbracelet/crush/internal/config" + "github.com/charmbracelet/crush/internal/home" "github.com/charmbracelet/crush/internal/tui/util" ) @@ -54,7 +55,7 @@ func buildCommandSources(cfg *config.Config) []commandSource { } // Home directory - if home, err := os.UserHomeDir(); err == nil { + if home := home.Dir(); home != "" { sources = append(sources, commandSource{ path: filepath.Join(home, ".crush", "commands"), prefix: UserCommandPrefix, @@ -73,7 +74,7 @@ func buildCommandSources(cfg *config.Config) []commandSource { func getXDGCommandsDir() string { xdgHome := os.Getenv("XDG_CONFIG_HOME") if xdgHome == "" { - if home, err := os.UserHomeDir(); err == nil { + if home := home.Dir(); home != "" { xdgHome = filepath.Join(home, ".config") } } diff --git a/internal/tui/components/dialogs/filepicker/filepicker.go b/internal/tui/components/dialogs/filepicker/filepicker.go index fd853cdc1e2f7ae8a049aa0c7f456cc406c41d88..fcec2fc8b6e3e606e555c55949049f397a30f921 100644 --- a/internal/tui/components/dialogs/filepicker/filepicker.go +++ b/internal/tui/components/dialogs/filepicker/filepicker.go @@ -11,6 +11,7 @@ import ( "github.com/charmbracelet/bubbles/v2/help" "github.com/charmbracelet/bubbles/v2/key" tea "github.com/charmbracelet/bubbletea/v2" + "github.com/charmbracelet/crush/internal/home" "github.com/charmbracelet/crush/internal/message" "github.com/charmbracelet/crush/internal/tui/components/core" "github.com/charmbracelet/crush/internal/tui/components/dialogs" @@ -60,7 +61,7 @@ func NewFilePickerCmp(workingDir string) FilePicker { if cwd, err := os.Getwd(); err == nil { fp.CurrentDirectory = cwd } else { - fp.CurrentDirectory, _ = os.UserHomeDir() + fp.CurrentDirectory = home.Dir() } } @@ -106,8 +107,7 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } if key.Matches(msg, m.filePicker.KeyMap.Back) { // make sure we don't go back if we are at the home directory - homeDir, _ := os.UserHomeDir() - if m.filePicker.CurrentDirectory == homeDir { + if m.filePicker.CurrentDirectory == home.Dir() { return m, nil } } diff --git a/internal/tui/components/dialogs/models/apikey.go b/internal/tui/components/dialogs/models/apikey.go index 80f812cd9c5e92313089aec70f9b9dba4b75375d..0490335f9ad745839a94de0460a0fc5c1b6f125c 100644 --- a/internal/tui/components/dialogs/models/apikey.go +++ b/internal/tui/components/dialogs/models/apikey.go @@ -2,13 +2,12 @@ package models import ( "fmt" - "strings" "github.com/charmbracelet/bubbles/v2/spinner" "github.com/charmbracelet/bubbles/v2/textinput" tea "github.com/charmbracelet/bubbletea/v2" "github.com/charmbracelet/crush/internal/config" - "github.com/charmbracelet/crush/internal/fsext" + "github.com/charmbracelet/crush/internal/home" "github.com/charmbracelet/crush/internal/tui/styles" "github.com/charmbracelet/lipgloss/v2" ) @@ -145,7 +144,7 @@ func (a *APIKeyInput) View() string { inputView := a.input.View() dataPath := config.GlobalConfigData() - dataPath = strings.Replace(dataPath, fsext.HomeDir(), "~", 1) + dataPath = home.Short(dataPath) helpText := styles.CurrentTheme().S().Muted. Render(fmt.Sprintf("This will be written to the global configuration: %s", dataPath))