shelley: directory picker: worktree root button + sort hidden dirs last

Philip Zeyliger and Shelley created

Add a "Go to git root" button in the directory picker when the current
directory is a git worktree. The button shows the main repository path
and navigates there on click.

Sort directory entries so hidden directories (.*) appear after
non-hidden ones, alphabetically within each group.

Prompt: In the directory selector, if the current directory is a worktree for another git repo, add a "go to git root" button right below the subject/git info, so that a user can quickly navigate there. Also, sort hidden files (.*) last, below the non-hidden ones in the directory list
Co-authored-by: Shelley <shelley@exe.dev>

Change summary

server/cwd_test.go                         | 119 ++++++++++++++++++-----
server/server.go                           |  59 +++++++++++
ui/src/components/DirectoryPickerModal.tsx |  26 +++++
ui/src/services/api.ts                     |   1 
ui/src/styles.css                          |  33 ++++++
5 files changed, 209 insertions(+), 29 deletions(-)

Detailed changes

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")

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 {

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({
             </div>
           )}
 
+          {/* Go to git root button for worktrees */}
+          {displayDir?.git_worktree_root && (
+            <button
+              className="directory-picker-git-root-btn"
+              onClick={() => setInputPath(displayDir.git_worktree_root + "/")}
+            >
+              <svg
+                fill="none"
+                stroke="currentColor"
+                viewBox="0 0 24 24"
+                className="directory-picker-icon"
+              >
+                <path
+                  strokeLinecap="round"
+                  strokeLinejoin="round"
+                  strokeWidth={2}
+                  d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"
+                />
+              </svg>
+              <span>Go to git root</span>
+              <span className="directory-picker-git-root-path">{displayDir.git_worktree_root}</span>
+            </button>
+          )}
+
           {/* Error message */}
           {error && <div className="directory-picker-error">{error}</div>}
 

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

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);