diff --git a/server/cwd_test.go b/server/cwd_test.go index 067ecf58e27e17b1af77da67b2ada908cedfdf64..f59dbdf701b782ba92efe4c525b6f5d25f3036e5 100644 --- a/server/cwd_test.go +++ b/server/cwd_test.go @@ -208,24 +208,18 @@ func TestListDirectory(t *testing.T) { } }) - t.Run("hidden_directories_included", func(t *testing.T) { - // Create a temp directory with a hidden directory + t.Run("hidden_directories_sorted_last", func(t *testing.T) { + // Create a temp directory with hidden and non-hidden directories tmpDir, err := os.MkdirTemp("", "listdir_hidden_test") if err != nil { t.Fatalf("failed to create temp dir: %v", err) } defer os.RemoveAll(tmpDir) - // Create a visible subdirectory - visibleDir := tmpDir + "/visible" - if err := os.Mkdir(visibleDir, 0o755); err != nil { - t.Fatalf("failed to create visible dir: %v", err) - } - - // Create a hidden subdirectory - hiddenDir := tmpDir + "/.hidden" - if err := os.Mkdir(hiddenDir, 0o755); err != nil { - t.Fatalf("failed to create hidden dir: %v", err) + for _, name := range []string{".alpha", "beta", ".gamma", "delta", "alpha"} { + if err := os.Mkdir(filepath.Join(tmpDir, name), 0o755); err != nil { + t.Fatalf("failed to create dir %s: %v", name, err) + } } req := httptest.NewRequest("GET", "/api/list-directory?path="+tmpDir, nil) @@ -241,21 +235,16 @@ func TestListDirectory(t *testing.T) { t.Fatalf("failed to parse response: %v", err) } - // Should include both visible and hidden directories - if len(resp.Entries) != 2 { - t.Errorf("expected 2 entries, got: %d", len(resp.Entries)) + if len(resp.Entries) != 5 { + t.Fatalf("expected 5 entries, got: %d", len(resp.Entries)) } - // Check that both directories are present (sorted alphabetically, hidden first) - names := make(map[string]bool) - for _, e := range resp.Entries { - names[e.Name] = true - } - if !names[".hidden"] { - t.Errorf("expected .hidden to be included") - } - if !names["visible"] { - t.Errorf("expected visible to be included") + // Non-hidden sorted first, then hidden sorted + want := []string{"alpha", "beta", "delta", ".alpha", ".gamma"} + for i, e := range resp.Entries { + if e.Name != want[i] { + t.Errorf("entry[%d]: expected %q, got %q", i, want[i], e.Name) + } } }) @@ -360,6 +349,86 @@ func TestListDirectory(t *testing.T) { } }) + t.Run("git_worktree_root", func(t *testing.T) { + // Create a main git repo and a worktree, then verify that + // listing the worktree returns git_worktree_root pointing to the main repo. + tmpDir, err := os.MkdirTemp("", "listdir_wtroot_test") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + mainRepo := filepath.Join(tmpDir, "main-repo") + if err := os.Mkdir(mainRepo, 0o755); err != nil { + t.Fatalf("failed to create main repo dir: %v", err) + } + + for _, args := range [][]string{ + {"git", "init"}, + {"git", "config", "user.email", "test@example.com"}, + {"git", "config", "user.name", "Test User"}, + } { + cmd := exec.Command(args[0], args[1:]...) + cmd.Dir = mainRepo + if err := cmd.Run(); err != nil { + t.Fatalf("%v failed: %v", args, err) + } + } + + if err := os.WriteFile(filepath.Join(mainRepo, "README.md"), []byte("# Hi"), 0o644); err != nil { + t.Fatal(err) + } + cmd := exec.Command("git", "add", ".") + cmd.Dir = mainRepo + if err := cmd.Run(); err != nil { + t.Fatal(err) + } + cmd = exec.Command("git", "commit", "-m", "init\n\nPrompt: test") + cmd.Dir = mainRepo + if err := cmd.Run(); err != nil { + t.Fatal(err) + } + + // Create a worktree + cmd = exec.Command("git", "branch", "wt-branch") + cmd.Dir = mainRepo + if err := cmd.Run(); err != nil { + t.Fatal(err) + } + worktreePath := filepath.Join(tmpDir, "my-worktree") + cmd = exec.Command("git", "worktree", "add", worktreePath, "wt-branch") + cmd.Dir = mainRepo + if err := cmd.Run(); err != nil { + t.Fatal(err) + } + + // List the worktree directory itself - should have git_worktree_root + req := httptest.NewRequest("GET", "/api/list-directory?path="+worktreePath, nil) + w := httptest.NewRecorder() + h.server.handleListDirectory(w, req) + + var resp ListDirectoryResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to parse response: %v", err) + } + if resp.GitWorktreeRoot != mainRepo { + t.Errorf("expected git_worktree_root=%q, got %q", mainRepo, resp.GitWorktreeRoot) + } + + // List the main repo directory - should NOT have git_worktree_root + req = httptest.NewRequest("GET", "/api/list-directory?path="+mainRepo, nil) + w = httptest.NewRecorder() + h.server.handleListDirectory(w, req) + + var resp2 ListDirectoryResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp2); err != nil { + t.Fatalf("failed to parse response: %v", err) + } + if resp2.GitWorktreeRoot != "" { + t.Errorf("main repo should not have git_worktree_root, got %q", resp2.GitWorktreeRoot) + } + }) + t.Run("git_worktree_head_subject", func(t *testing.T) { // Create a temp directory containing a git repo and a worktree tmpDir, err := os.MkdirTemp("", "listdir_worktree_test") diff --git a/server/server.go b/server/server.go index 1e3924eb5da2974e028de88e397bf353e0968652..47086aaea30f978b30d0cf52c328505bd99cb4cf 100644 --- a/server/server.go +++ b/server/server.go @@ -11,6 +11,7 @@ import ( "os/exec" "os/signal" "path/filepath" + "sort" "strings" "sync" "syscall" @@ -354,10 +355,11 @@ type DirectoryEntry struct { // ListDirectoryResponse is the response from the list-directory endpoint type ListDirectoryResponse struct { - Path string `json:"path"` - Parent string `json:"parent"` - Entries []DirectoryEntry `json:"entries"` - GitHeadSubject string `json:"git_head_subject,omitempty"` + Path string `json:"path"` + Parent string `json:"parent"` + Entries []DirectoryEntry `json:"entries"` + GitHeadSubject string `json:"git_head_subject,omitempty"` + GitWorktreeRoot string `json:"git_worktree_root,omitempty"` } // handleListDirectory lists the contents of a directory for the directory picker @@ -447,6 +449,16 @@ func (s *Server) handleListDirectory(w http.ResponseWriter, r *http.Request) { } } + // Sort entries: non-hidden first, then hidden (.*), alphabetically within each group + sort.Slice(entries, func(i, j int) bool { + iHidden := strings.HasPrefix(entries[i].Name, ".") + jHidden := strings.HasPrefix(entries[j].Name, ".") + if iHidden != jHidden { + return !iHidden // non-hidden comes first + } + return entries[i].Name < entries[j].Name + }) + // Calculate parent directory parent := filepath.Dir(path) if parent == path { @@ -463,6 +475,9 @@ func (s *Server) handleListDirectory(w http.ResponseWriter, r *http.Request) { // Check if the current directory itself is a git repo if isGitRepo(path) { response.GitHeadSubject = getGitHeadSubject(path) + if root := getGitWorktreeRoot(path); root != "" { + response.GitWorktreeRoot = root + } } w.Header().Set("Content-Type", "application/json") @@ -504,6 +519,42 @@ func getGitHeadSubject(repoPath string) string { return strings.TrimSpace(string(output)) } +// getGitWorktreeRoot returns the main repository root if the given path is +// a git worktree (not the main repo itself). Returns "" otherwise. +func getGitWorktreeRoot(repoPath string) string { + // Get the worktree's git dir and the common (main repo) git dir + cmd := exec.Command("git", "rev-parse", "--git-dir", "--git-common-dir") + cmd.Dir = repoPath + output, err := cmd.Output() + if err != nil { + return "" + } + lines := strings.SplitN(strings.TrimSpace(string(output)), "\n", 2) + if len(lines) != 2 { + return "" + } + gitDir := lines[0] + commonDir := lines[1] + + // Resolve relative paths + if !filepath.IsAbs(gitDir) { + gitDir = filepath.Join(repoPath, gitDir) + } + if !filepath.IsAbs(commonDir) { + commonDir = filepath.Join(repoPath, commonDir) + } + gitDir = filepath.Clean(gitDir) + commonDir = filepath.Clean(commonDir) + + // If they're the same, this is the main repo, not a worktree + if gitDir == commonDir { + return "" + } + + // The main repo root is the parent of the common .git dir + return filepath.Dir(commonDir) +} + // handleCreateDirectory creates a new directory func (s *Server) handleCreateDirectory(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { diff --git a/ui/src/components/DirectoryPickerModal.tsx b/ui/src/components/DirectoryPickerModal.tsx index 07d68f59bf89c4274ac62668951d42b8ad5eede6..eb6210c70939d1ad340386370beacc4c43c1cbd4 100644 --- a/ui/src/components/DirectoryPickerModal.tsx +++ b/ui/src/components/DirectoryPickerModal.tsx @@ -12,6 +12,7 @@ interface CachedDirectory { parent: string; entries: DirectoryEntry[]; git_head_subject?: string; + git_worktree_root?: string; } interface DirectoryPickerModalProps { @@ -103,6 +104,7 @@ function DirectoryPickerModal({ parent: result.parent, entries: result.entries || [], git_head_subject: result.git_head_subject, + git_worktree_root: result.git_worktree_root, }; // Cache it @@ -342,6 +344,30 @@ function DirectoryPickerModal({ )} + {/* Go to git root button for worktrees */} + {displayDir?.git_worktree_root && ( + + )} + {/* Error message */} {error &&
{error}
} diff --git a/ui/src/services/api.ts b/ui/src/services/api.ts index ffc1dba4a985f3b6c275affa4c586d69415f96a4..39c01b6ca3637e7d555faeb3bb24d3fb55743551 100644 --- a/ui/src/services/api.ts +++ b/ui/src/services/api.ts @@ -137,6 +137,7 @@ class ApiService { parent: string; entries: Array<{ name: string; is_dir: boolean; git_head_subject?: string }>; git_head_subject?: string; + git_worktree_root?: string; error?: string; }> { const url = path diff --git a/ui/src/styles.css b/ui/src/styles.css index 9a9502d3e0f5bb05929f22d4634b70912a48d0ff..dfa8106c5949f17122e397e9ba30488dc6f47baf 100644 --- a/ui/src/styles.css +++ b/ui/src/styles.css @@ -2891,6 +2891,39 @@ svg { font-weight: 500; } +.directory-picker-git-root-btn { + display: flex; + align-items: center; + gap: 0.5rem; + width: 100%; + padding: 0.375rem 0.75rem; + margin-bottom: 0.5rem; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 0.375rem; + color: var(--accent-color, #f97316); + font-size: 0.8125rem; + cursor: pointer; + text-align: left; +} + +.directory-picker-git-root-btn:hover { + background: var(--bg-tertiary); + border-color: var(--accent-color, #f97316); +} + +.directory-picker-git-root-btn .directory-picker-icon { + flex-shrink: 0; +} + +.directory-picker-git-root-path { + color: var(--text-tertiary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + margin-left: auto; +} + .directory-picker-error { padding: 0.5rem 0.75rem; background: var(--error-bg);