on main help sign in
refactor: home.Dir, home.Short, home.Long (#884)

* refactor: home.Dir, home.Short, home.Long Centralized all home-related operations in a package and removed a bunch of repeated code all over the place. Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com> * test: more cases Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com> * fix: more places Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com> * test: fix on windows Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com> * test: fix Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com> * test: fix Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com> --------- Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>

commit8d735d6e3d859a5d61d835372b1e0b497c5ee4d5 authorCarlos Alexandro Becker <caarlos0@users.noreply.github.com> dateWed 27 Aug 2025, 14:54 (1 year ago) parent3b9babb
15 files changed+98−124
internal/config/load.go+3−2 internal/config/provider.go+2−1 internal/fsext/fileutil.go+2−6 internal/fsext/home.go−20 internal/fsext/ls.go+2−1 internal/fsext/parent.go+3−1 internal/home/home.go+42 internal/home/home_test.go+26 internal/llm/prompt/prompt.go+2−12 internal/llm/prompt/prompt_test.go+4−58 internal/tui/components/chat/sidebar/sidebar.go+2−8 internal/tui/components/chat/splash/splash.go+2−7 internal/tui/components/dialogs/commands/loader.go+3−2 internal/tui/components/dialogs/filepicker/filepicker.go+3−3 internal/tui/components/dialogs/models/apikey.go+2−3
internal/config/load.go+3−2
@@ -16,6 +16,7 @@ import (
1616 "github.com/charmbracelet/crush/internal/csync"
1717 "github.com/charmbracelet/crush/internal/env"
1818 "github.com/charmbracelet/crush/internal/fsext"
19+ "github.com/charmbracelet/crush/internal/home"
1920 "github.com/charmbracelet/crush/internal/log"
2021)
2122
@@ -584,7 +585,7 @@ func globalConfig() string {
584585 return filepath.Join(localAppData, appName, fmt.Sprintf("%s.json", appName))
585586 }
586587
587− return filepath.Join(os.Getenv("HOME"), ".config", appName, fmt.Sprintf("%s.json", appName))
588+ return filepath.Join(home.Dir(), ".config", appName, fmt.Sprintf("%s.json", appName))
588589}
589590
590591// GlobalConfigData returns the path to the main data directory for the application.
@@ -606,5 +607,5 @@ func GlobalConfigData() string {
606607 return filepath.Join(localAppData, appName, fmt.Sprintf("%s.json", appName))
607608 }
608609
609− return filepath.Join(os.Getenv("HOME"), ".local", "share", appName, fmt.Sprintf("%s.json", appName))
610+ return filepath.Join(home.Dir(), ".local", "share", appName, fmt.Sprintf("%s.json", appName))
610611}
internal/config/provider.go+2−1
@@ -12,6 +12,7 @@ import (
1212 "time"
1313
1414 "github.com/charmbracelet/catwalk/pkg/catwalk"
15+ "github.com/charmbracelet/crush/internal/home"
1516)
1617
1718type ProviderClient interface {
@@ -41,7 +42,7 @@ func providerCacheFileData() string {
4142 return filepath.Join(localAppData, appName, "providers.json")
4243 }
4344
44− return filepath.Join(os.Getenv("HOME"), ".local", "share", appName, "providers.json")
45+ return filepath.Join(home.Dir(), ".local", "share", appName, "providers.json")
4546}
4647
4748func saveProvidersInCache(path string, providers []catwalk.Provider) error {
internal/fsext/fileutil.go+2−6
@@ -10,6 +10,7 @@ import (
1010
1111 "github.com/bmatcuk/doublestar/v4"
1212 "github.com/charlievieth/fastwalk"
13+ "github.com/charmbracelet/crush/internal/home"
1314
1415 ignore "github.com/sabhiram/go-gitignore"
1516)
@@ -182,12 +183,7 @@ func GlobWithDoubleStar(pattern, searchPath string, limit int) ([]string, bool,
182183}
183184
184185func PrettyPath(path string) string {
185− // replace home directory with ~
186− homeDir, err := os.UserHomeDir()
187− if err == nil {
188− path = strings.ReplaceAll(path, homeDir, "~")
189− }
190− return path
186+ return home.Short(path)
191187}
192188
193189func DirTrim(pwd string, lim int) string {
internal/fsext/home.godeleted−20
@@ -1,20 +0,0 @@
1−package fsext
2−
3−import (
4− "cmp"
5− "os"
6− "os/user"
7− "sync"
8−)
9−
10−var HomeDir = sync.OnceValue(func() string {
11− u, err := user.Current()
12− if err == nil {
13− return u.HomeDir
14− }
15− return cmp.Or(
16− os.Getenv("HOME"),
17− os.Getenv("USERPROFILE"),
18− os.Getenv("HOMEPATH"),
19− )
20−})
internal/fsext/ls.go+2−1
@@ -9,6 +9,7 @@ import (
99
1010 "github.com/charlievieth/fastwalk"
1111 "github.com/charmbracelet/crush/internal/csync"
12+ "github.com/charmbracelet/crush/internal/home"
1213 ignore "github.com/sabhiram/go-gitignore"
1314)
1415
@@ -73,7 +74,7 @@ var commonIgnorePatterns = sync.OnceValue(func() ignore.IgnoreParser {
7374})
7475
7576var homeIgnore = sync.OnceValue(func() ignore.IgnoreParser {
76− home := HomeDir()
77+ home := home.Dir()
7778 var lines []string
7879 for _, name := range []string{
7980 filepath.Join(home, ".gitignore"),
internal/fsext/parent.go+3−1
@@ -4,6 +4,8 @@ import (
44 "errors"
55 "os"
66 "path/filepath"
7+
8+ "github.com/charmbracelet/crush/internal/home"
79)
810
911// SearchParent searches for a target file or directory starting from dir
@@ -33,7 +35,7 @@ func SearchParent(dir, target string) (string, bool) {
3335
3436 for {
3537 parent := filepath.Dir(previousParent)
36− if parent == previousParent || parent == HomeDir() {
38+ if parent == previousParent || parent == home.Dir() {
3739 return "", false
3840 }
3941
internal/home/home.gonew file+42
@@ -0,0 +1,42 @@
1+package home
2+
3+import (
4+ "log/slog"
5+ "os"
6+ "path/filepath"
7+ "strings"
8+ "sync"
9+)
10+
11+// Dir returns the users home directory, or if it fails, tries to create a new
12+// temporary directory and use that instead.
13+var Dir = sync.OnceValue(func() string {
14+ home, err := os.UserHomeDir()
15+ if err == nil {
16+ slog.Debug("user home directory", "home", home)
17+ return home
18+ }
19+ tmp, err := os.MkdirTemp("crush", "")
20+ if err != nil {
21+ slog.Error("could not find the user home directory")
22+ return ""
23+ }
24+ slog.Warn("could not find the user home directory, using a temporary one", "home", tmp)
25+ return tmp
26+})
27+
28+// Short replaces the actual home path from [Dir] with `~`.
29+func Short(p string) string {
30+ if !strings.HasPrefix(p, Dir()) || Dir() == "" {
31+ return p
32+ }
33+ return filepath.Join("~", strings.TrimPrefix(p, Dir()))
34+}
35+
36+// Long replaces the `~` with actual home path from [Dir].
37+func Long(p string) string {
38+ if !strings.HasPrefix(p, "~") || Dir() == "" {
39+ return p
40+ }
41+ return strings.Replace(p, "~", Dir(), 1)
42+}
internal/home/home_test.gonew file+26
@@ -0,0 +1,26 @@
1+package home
2+
3+import (
4+ "path/filepath"
5+ "testing"
6+
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestDir(t *testing.T) {
11+ require.NotEmpty(t, Dir())
12+}
13+
14+func TestShort(t *testing.T) {
15+ d := filepath.Join(Dir(), "documents", "file.txt")
16+ require.Equal(t, filepath.FromSlash("~/documents/file.txt"), Short(d))
17+ ad := filepath.FromSlash("/absolute/path/file.txt")
18+ require.Equal(t, ad, Short(ad))
19+}
20+
21+func TestLong(t *testing.T) {
22+ d := filepath.FromSlash("~/documents/file.txt")
23+ require.Equal(t, filepath.Join(Dir(), "documents", "file.txt"), Long(d))
24+ ad := filepath.FromSlash("/absolute/path/file.txt")
25+ require.Equal(t, ad, Long(ad))
26+}
internal/llm/prompt/prompt.go+2−12
@@ -9,6 +9,7 @@ import (
99 "github.com/charmbracelet/crush/internal/config"
1010 "github.com/charmbracelet/crush/internal/csync"
1111 "github.com/charmbracelet/crush/internal/env"
12+ "github.com/charmbracelet/crush/internal/home"
1213)
1314
1415type PromptID string
@@ -44,18 +45,7 @@ func getContextFromPaths(workingDir string, contextPaths []string) string {
4445
4546// expandPath expands ~ and environment variables in file paths
4647func expandPath(path string) string {
47− // Handle tilde expansion
48− if strings.HasPrefix(path, "~/") {
49− homeDir, err := os.UserHomeDir()
50− if err == nil {
51− path = filepath.Join(homeDir, path[2:])
52− }
53− } else if path == "~" {
54− homeDir, err := os.UserHomeDir()
55− if err == nil {
56− path = homeDir
57− }
58− }
48+ path = home.Long(path)
5949
6050 // Handle environment variable expansion using the same pattern as config
6151 if strings.HasPrefix(path, "$") {
internal/llm/prompt/prompt_test.go+4−58
@@ -2,10 +2,10 @@ package prompt
22
33import (
44 "os"
5− "path/filepath"
6− "runtime"
75 "strings"
86 "testing"
7+
8+ "github.com/charmbracelet/crush/internal/home"
99)
1010
1111func TestExpandPath(t *testing.T) {
@@ -25,16 +25,14 @@ func TestExpandPath(t *testing.T) {
2525 name: "tilde expansion",
2626 input: "~/documents",
2727 expected: func() string {
28− home, _ := os.UserHomeDir()
29− return filepath.Join(home, "documents")
28+ return home.Dir() + "/documents"
3029 },
3130 },
3231 {
3332 name: "tilde only",
3433 input: "~",
3534 expected: func() string {
36− home, _ := os.UserHomeDir()
37− return home
35+ return home.Dir()
3836 },
3937 },
4038 {
@@ -69,55 +67,3 @@ func TestExpandPath(t *testing.T) {
6967 })
7068 }
7169}
72−
73−func TestProcessContextPaths(t *testing.T) {
74− // Create a temporary directory and file for testing
75− tmpDir := t.TempDir()
76− testFile := filepath.Join(tmpDir, "test.txt")
77− testContent := "test content"
78−
79− err := os.WriteFile(testFile, []byte(testContent), 0o644)
80− if err != nil {
81− t.Fatalf("Failed to create test file: %v", err)
82− }
83−
84− // Test with absolute path to file
85− result := processContextPaths("", []string{testFile})
86− expected := "# From:" + testFile + "\n" + testContent
87−
88− if result != expected {
89− t.Errorf("processContextPaths with absolute path failed.\nGot: %q\nWant: %q", result, expected)
90− }
91−
92− // Test with directory path (should process all files in directory)
93− result = processContextPaths("", []string{tmpDir})
94− if !strings.Contains(result, testContent) {
95− t.Errorf("processContextPaths with directory path failed to include file content")
96− }
97−
98− // Test with tilde expansion (if we can create a file in home directory)
99− tmpDir = t.TempDir()
100− setHomeEnv(t, tmpDir)
101− homeTestFile := filepath.Join(tmpDir, "crush_test_file.txt")
102− err = os.WriteFile(homeTestFile, []byte(testContent), 0o644)
103− if err == nil {
104− defer os.Remove(homeTestFile) // Clean up
105−
106− tildeFile := "~/crush_test_file.txt"
107− result = processContextPaths("", []string{tildeFile})
108− expected = "# From:" + homeTestFile + "\n" + testContent
109−
110− if result != expected {
111− t.Errorf("processContextPaths with tilde expansion failed.\nGot: %q\nWant: %q", result, expected)
112− }
113− }
114−}
115−
116−func setHomeEnv(tb testing.TB, path string) {
117− tb.Helper()
118− key := "HOME"
119− if runtime.GOOS == "windows" {
120− key = "USERPROFILE"
121− }
122− tb.Setenv(key, path)
123−}
internal/tui/components/chat/sidebar/sidebar.go+2−8
@@ -3,7 +3,6 @@ package sidebar
33import (
44 "context"
55 "fmt"
6− "os"
76 "slices"
87 "strings"
98
@@ -14,6 +13,7 @@ import (
1413 "github.com/charmbracelet/crush/internal/diff"
1514 "github.com/charmbracelet/crush/internal/fsext"
1615 "github.com/charmbracelet/crush/internal/history"
16+ "github.com/charmbracelet/crush/internal/home"
1717 "github.com/charmbracelet/crush/internal/lsp"
1818 "github.com/charmbracelet/crush/internal/pubsub"
1919 "github.com/charmbracelet/crush/internal/session"
@@ -609,11 +609,5 @@ func (m *sidebarCmp) SetCompactMode(compact bool) {
609609func cwd() string {
610610 cwd := config.Get().WorkingDir()
611611 t := styles.CurrentTheme()
612− // Replace home directory with ~, unless we're at the top level of the
613− // home directory).
614− homeDir, err := os.UserHomeDir()
615− if err == nil && cwd != homeDir {
616− cwd = strings.ReplaceAll(cwd, homeDir, "~")
617− }
618− return t.S().Muted.Render(cwd)
612+ return t.S().Muted.Render(home.Short(cwd))
619613}
internal/tui/components/chat/splash/splash.go+2−7
@@ -2,7 +2,6 @@ package splash
22
33import (
44 "fmt"
5− "os"
65 "strings"
76 "time"
87
@@ -11,6 +10,7 @@ import (
1110 tea "github.com/charmbracelet/bubbletea/v2"
1211 "github.com/charmbracelet/catwalk/pkg/catwalk"
1312 "github.com/charmbracelet/crush/internal/config"
13+ "github.com/charmbracelet/crush/internal/home"
1414 "github.com/charmbracelet/crush/internal/llm/prompt"
1515 "github.com/charmbracelet/crush/internal/tui/components/chat"
1616 "github.com/charmbracelet/crush/internal/tui/components/core"
@@ -648,12 +648,7 @@ func (s *splashCmp) cwdPart() string {
648648}
649649
650650func (s *splashCmp) cwd() string {
651− cwd := config.Get().WorkingDir()
652− homeDir, err := os.UserHomeDir()
653− if err == nil && cwd != homeDir {
654− cwd = strings.ReplaceAll(cwd, homeDir, "~")
655− }
656− return cwd
651+ return home.Short(config.Get().WorkingDir())
657652}
658653
659654func LSPList(maxWidth int) []string {
internal/tui/components/dialogs/commands/loader.go+3−2
@@ -10,6 +10,7 @@ import (
1010
1111 tea "github.com/charmbracelet/bubbletea/v2"
1212 "github.com/charmbracelet/crush/internal/config"
13+ "github.com/charmbracelet/crush/internal/home"
1314 "github.com/charmbracelet/crush/internal/tui/util"
1415)
1516
@@ -54,7 +55,7 @@ func buildCommandSources(cfg *config.Config) []commandSource {
5455 }
5556
5657 // Home directory
57− if home, err := os.UserHomeDir(); err == nil {
58+ if home := home.Dir(); home != "" {
5859 sources = append(sources, commandSource{
5960 path: filepath.Join(home, ".crush", "commands"),
6061 prefix: UserCommandPrefix,
@@ -73,7 +74,7 @@ func buildCommandSources(cfg *config.Config) []commandSource {
7374func getXDGCommandsDir() string {
7475 xdgHome := os.Getenv("XDG_CONFIG_HOME")
7576 if xdgHome == "" {
76− if home, err := os.UserHomeDir(); err == nil {
77+ if home := home.Dir(); home != "" {
7778 xdgHome = filepath.Join(home, ".config")
7879 }
7980 }
internal/tui/components/dialogs/filepicker/filepicker.go+3−3
@@ -11,6 +11,7 @@ import (
1111 "github.com/charmbracelet/bubbles/v2/help"
1212 "github.com/charmbracelet/bubbles/v2/key"
1313 tea "github.com/charmbracelet/bubbletea/v2"
14+ "github.com/charmbracelet/crush/internal/home"
1415 "github.com/charmbracelet/crush/internal/message"
1516 "github.com/charmbracelet/crush/internal/tui/components/core"
1617 "github.com/charmbracelet/crush/internal/tui/components/dialogs"
@@ -60,7 +61,7 @@ func NewFilePickerCmp(workingDir string) FilePicker {
6061 if cwd, err := os.Getwd(); err == nil {
6162 fp.CurrentDirectory = cwd
6263 } else {
63− fp.CurrentDirectory, _ = os.UserHomeDir()
64+ fp.CurrentDirectory = home.Dir()
6465 }
6566 }
6667
@@ -106,8 +107,7 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
106107 }
107108 if key.Matches(msg, m.filePicker.KeyMap.Back) {
108109 // make sure we don't go back if we are at the home directory
109− homeDir, _ := os.UserHomeDir()
110− if m.filePicker.CurrentDirectory == homeDir {
110+ if m.filePicker.CurrentDirectory == home.Dir() {
111111 return m, nil
112112 }
113113 }
internal/tui/components/dialogs/models/apikey.go+2−3
@@ -2,13 +2,12 @@ package models
22
33import (
44 "fmt"
5− "strings"
65
76 "github.com/charmbracelet/bubbles/v2/spinner"
87 "github.com/charmbracelet/bubbles/v2/textinput"
98 tea "github.com/charmbracelet/bubbletea/v2"
109 "github.com/charmbracelet/crush/internal/config"
11− "github.com/charmbracelet/crush/internal/fsext"
10+ "github.com/charmbracelet/crush/internal/home"
1211 "github.com/charmbracelet/crush/internal/tui/styles"
1312 "github.com/charmbracelet/lipgloss/v2"
1413)
@@ -145,7 +144,7 @@ func (a *APIKeyInput) View() string {
145144 inputView := a.input.View()
146145
147146 dataPath := config.GlobalConfigData()
148− dataPath = strings.Replace(dataPath, fsext.HomeDir(), "~", 1)
147+ dataPath = home.Short(dataPath)
149148 helpText := styles.CurrentTheme().S().Muted.
150149 Render(fmt.Sprintf("This will be written to the global configuration: %s", dataPath))
151150