@@ -6,6 +6,7 @@ import (
"net/http"
"net/http/httptest"
"os"
+ "os/exec"
"path/filepath"
"strings"
"testing"
@@ -207,7 +208,7 @@ func TestListDirectory(t *testing.T) {
}
})
- t.Run("hidden_directories_excluded", func(t *testing.T) {
+ t.Run("hidden_directories_included", func(t *testing.T) {
// Create a temp directory with a hidden directory
tmpDir, err := os.MkdirTemp("", "listdir_hidden_test")
if err != nil {
@@ -240,13 +241,227 @@ func TestListDirectory(t *testing.T) {
t.Fatalf("failed to parse response: %v", err)
}
- // Should only include the visible directory, not the hidden one
- if len(resp.Entries) != 1 {
- t.Errorf("expected 1 entry, got: %d", len(resp.Entries))
+ // Should include both visible and hidden directories
+ if len(resp.Entries) != 2 {
+ t.Errorf("expected 2 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")
+ }
+ })
+
+ t.Run("git_repo_head_subject", func(t *testing.T) {
+ // Create a temp directory containing a git repo
+ tmpDir, err := os.MkdirTemp("", "listdir_git_test")
+ if err != nil {
+ t.Fatalf("failed to create temp dir: %v", err)
+ }
+ defer os.RemoveAll(tmpDir)
+
+ // Create a subdirectory that will be a git repo
+ repoDir := tmpDir + "/myrepo"
+ if err := os.Mkdir(repoDir, 0o755); err != nil {
+ t.Fatalf("failed to create repo dir: %v", err)
+ }
+
+ // Initialize git repo and create a commit
+ cmd := exec.Command("git", "init")
+ cmd.Dir = repoDir
+ if err := cmd.Run(); err != nil {
+ t.Fatalf("failed to init git: %v", err)
+ }
+
+ cmd = exec.Command("git", "config", "user.email", "test@example.com")
+ cmd.Dir = repoDir
+ if err := cmd.Run(); err != nil {
+ t.Fatalf("failed to config git email: %v", err)
+ }
+
+ cmd = exec.Command("git", "config", "user.name", "Test User")
+ cmd.Dir = repoDir
+ if err := cmd.Run(); err != nil {
+ t.Fatalf("failed to config git name: %v", err)
+ }
+
+ // Create a file and commit it
+ if err := os.WriteFile(repoDir+"/README.md", []byte("# Hello"), 0o644); err != nil {
+ t.Fatalf("failed to write file: %v", err)
+ }
+
+ cmd = exec.Command("git", "add", "README.md")
+ cmd.Dir = repoDir
+ if err := cmd.Run(); err != nil {
+ t.Fatalf("failed to git add: %v", err)
+ }
+
+ cmd = exec.Command("git", "commit", "-m", "Test commit subject line\n\nPrompt: test")
+ cmd.Dir = repoDir
+ if err := cmd.Run(); err != nil {
+ t.Fatalf("failed to git commit: %v", err)
+ }
+
+ // Create another directory that is not a git repo
+ nonRepoDir := tmpDir + "/notarepo"
+ if err := os.Mkdir(nonRepoDir, 0o755); err != nil {
+ t.Fatalf("failed to create non-repo dir: %v", err)
+ }
+
+ req := httptest.NewRequest("GET", "/api/list-directory?path="+tmpDir, nil)
+ w := httptest.NewRecorder()
+ h.server.handleListDirectory(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String())
+ }
+
+ var resp ListDirectoryResponse
+ if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("failed to parse response: %v", err)
+ }
+
+ if len(resp.Entries) != 2 {
+ t.Fatalf("expected 2 entries, got: %d", len(resp.Entries))
+ }
+
+ // Find the git repo entry and verify it has the commit subject
+ var gitEntry, nonGitEntry *DirectoryEntry
+ for i := range resp.Entries {
+ if resp.Entries[i].Name == "myrepo" {
+ gitEntry = &resp.Entries[i]
+ } else if resp.Entries[i].Name == "notarepo" {
+ nonGitEntry = &resp.Entries[i]
+ }
+ }
+
+ if gitEntry == nil {
+ t.Fatal("expected to find myrepo entry")
+ }
+ if nonGitEntry == nil {
+ t.Fatal("expected to find notarepo entry")
+ }
+
+ // Git repo should have the HEAD commit subject
+ if gitEntry.GitHeadSubject != "Test commit subject line" {
+ t.Errorf("expected git_head_subject 'Test commit subject line', got: %q", gitEntry.GitHeadSubject)
+ }
+
+ // Non-git dir should not have a subject
+ if nonGitEntry.GitHeadSubject != "" {
+ t.Errorf("expected empty git_head_subject for non-git dir, got: %q", nonGitEntry.GitHeadSubject)
+ }
+ })
+
+ 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")
+ if err != nil {
+ t.Fatalf("failed to create temp dir: %v", err)
+ }
+ defer os.RemoveAll(tmpDir)
+
+ // Create a main git repo
+ mainRepo := tmpDir + "/main-repo"
+ if err := os.Mkdir(mainRepo, 0o755); err != nil {
+ t.Fatalf("failed to create main repo dir: %v", err)
+ }
+
+ // Initialize git repo and create a commit
+ cmd := exec.Command("git", "init")
+ cmd.Dir = mainRepo
+ if err := cmd.Run(); err != nil {
+ t.Fatalf("failed to init git: %v", err)
+ }
+
+ cmd = exec.Command("git", "config", "user.email", "test@example.com")
+ cmd.Dir = mainRepo
+ if err := cmd.Run(); err != nil {
+ t.Fatalf("failed to config git email: %v", err)
+ }
+
+ cmd = exec.Command("git", "config", "user.name", "Test User")
+ cmd.Dir = mainRepo
+ if err := cmd.Run(); err != nil {
+ t.Fatalf("failed to config git name: %v", err)
+ }
+
+ // Create a file and commit it
+ if err := os.WriteFile(mainRepo+"/README.md", []byte("# Hello"), 0o644); err != nil {
+ t.Fatalf("failed to write file: %v", err)
+ }
+
+ cmd = exec.Command("git", "add", "README.md")
+ cmd.Dir = mainRepo
+ if err := cmd.Run(); err != nil {
+ t.Fatalf("failed to git add: %v", err)
+ }
+
+ cmd = exec.Command("git", "commit", "-m", "Main repo commit\n\nPrompt: test")
+ cmd.Dir = mainRepo
+ if err := cmd.Run(); err != nil {
+ t.Fatalf("failed to git commit: %v", err)
+ }
+
+ // Create a branch and worktree
+ cmd = exec.Command("git", "branch", "feature-branch")
+ cmd.Dir = mainRepo
+ if err := cmd.Run(); err != nil {
+ t.Fatalf("failed to create branch: %v", err)
+ }
+
+ worktreePath := tmpDir + "/worktree-dir"
+ cmd = exec.Command("git", "worktree", "add", worktreePath, "feature-branch")
+ cmd.Dir = mainRepo
+ if err := cmd.Run(); err != nil {
+ t.Fatalf("failed to create worktree: %v", err)
+ }
+
+ // Verify the worktree has a .git file (not directory)
+ gitPath := worktreePath + "/.git"
+ fi, err := os.Stat(gitPath)
+ if err != nil {
+ t.Fatalf("failed to stat worktree .git: %v", err)
+ }
+ if fi.IsDir() {
+ t.Fatalf("expected .git to be a file for worktree, got directory")
+ }
+
+ req := httptest.NewRequest("GET", "/api/list-directory?path="+tmpDir, nil)
+ w := httptest.NewRecorder()
+ h.server.handleListDirectory(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String())
+ }
+
+ var resp ListDirectoryResponse
+ if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("failed to parse response: %v", err)
+ }
+
+ // Find the worktree entry and verify it has the commit subject
+ var worktreeEntry *DirectoryEntry
+ for i := range resp.Entries {
+ if resp.Entries[i].Name == "worktree-dir" {
+ worktreeEntry = &resp.Entries[i]
+ }
+ }
+
+ if worktreeEntry == nil {
+ t.Fatal("expected to find worktree-dir entry")
}
- if len(resp.Entries) > 0 && resp.Entries[0].Name != "visible" {
- t.Errorf("expected entry 'visible', got: %s", resp.Entries[0].Name)
+ // Worktree should have the HEAD commit subject
+ if worktreeEntry.GitHeadSubject != "Main repo commit" {
+ t.Errorf("expected git_head_subject 'Main repo commit', got: %q", worktreeEntry.GitHeadSubject)
}
})
}
@@ -342,15 +342,17 @@ func (s *Server) handleValidateCwd(w http.ResponseWriter, r *http.Request) {
// DirectoryEntry represents a single directory entry for the directory picker
type DirectoryEntry struct {
- Name string `json:"name"`
- IsDir bool `json:"is_dir"`
+ Name string `json:"name"`
+ IsDir bool `json:"is_dir"`
+ GitHeadSubject string `json:"git_head_subject,omitempty"`
}
// ListDirectoryResponse is the response from the list-directory endpoint
type ListDirectoryResponse struct {
- Path string `json:"path"`
- Parent string `json:"parent"`
- Entries []DirectoryEntry `json:"entries"`
+ Path string `json:"path"`
+ Parent string `json:"parent"`
+ Entries []DirectoryEntry `json:"entries"`
+ GitHeadSubject string `json:"git_head_subject,omitempty"`
}
// handleListDirectory lists the contents of a directory for the directory picker
@@ -421,16 +423,22 @@ func (s *Server) handleListDirectory(w http.ResponseWriter, r *http.Request) {
// Build response with only directories (for directory picker)
var entries []DirectoryEntry
for _, entry := range dirEntries {
- // Skip hidden files/directories (starting with .)
- if strings.HasPrefix(entry.Name(), ".") {
- continue
- }
// Only include directories
if entry.IsDir() {
- entries = append(entries, DirectoryEntry{
+ dirEntry := DirectoryEntry{
Name: entry.Name(),
IsDir: true,
- })
+ }
+
+ // Check if this is a git repo root and get HEAD commit subject
+ entryPath := filepath.Join(path, entry.Name())
+ if isGitRepo(entryPath) {
+ if subject := getGitHeadSubject(entryPath); subject != "" {
+ dirEntry.GitHeadSubject = subject
+ }
+ }
+
+ entries = append(entries, dirEntry)
}
}
@@ -447,10 +455,50 @@ func (s *Server) handleListDirectory(w http.ResponseWriter, r *http.Request) {
Entries: entries,
}
+ // Check if the current directory itself is a git repo
+ if isGitRepo(path) {
+ response.GitHeadSubject = getGitHeadSubject(path)
+ }
+
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
+// getGitHeadSubject returns the subject line of HEAD commit for a git repository.
+// Returns empty string if unable to get the subject.
+// isGitRepo checks if the given path is a git repository root.
+// Returns true for both regular repos (.git directory) and worktrees (.git file with gitdir:).
+func isGitRepo(dirPath string) bool {
+ gitPath := filepath.Join(dirPath, ".git")
+ fi, err := os.Stat(gitPath)
+ if err != nil {
+ return false
+ }
+ if fi.IsDir() {
+ return true // regular .git directory
+ }
+ if fi.Mode().IsRegular() {
+ // Check if it's a worktree .git file
+ content, err := os.ReadFile(gitPath)
+ if err == nil && strings.HasPrefix(string(content), "gitdir:") {
+ return true
+ }
+ }
+ return false
+}
+
+// getGitHeadSubject returns the subject line of HEAD commit for a git repository.
+// Returns empty string if unable to get the subject.
+func getGitHeadSubject(repoPath string) string {
+ cmd := exec.Command("git", "log", "-1", "--format=%s")
+ cmd.Dir = repoPath
+ output, err := cmd.Output()
+ if err != nil {
+ return ""
+ }
+ return strings.TrimSpace(string(output))
+}
+
// handleCreateDirectory creates a new directory
func (s *Server) handleCreateDirectory(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
@@ -4,12 +4,14 @@ import { api } from "../services/api";
interface DirectoryEntry {
name: string;
is_dir: boolean;
+ git_head_subject?: string;
}
interface CachedDirectory {
path: string;
parent: string;
entries: DirectoryEntry[];
+ git_head_subject?: string;
}
interface DirectoryPickerModalProps {
@@ -100,6 +102,7 @@ function DirectoryPickerModal({
path: result.path,
parent: result.parent,
entries: result.entries || [],
+ git_head_subject: result.git_head_subject,
};
// Cache it
@@ -114,6 +117,9 @@ function DirectoryPickerModal({
}
}, []);
+ // Track the current expected path to avoid race conditions
+ const expectedPathRef = useRef<string>("");
+
// Update display when input changes
useEffect(() => {
if (!isOpen) return;
@@ -121,9 +127,14 @@ function DirectoryPickerModal({
const { dirPath, prefix } = parseInputPath(inputPath);
setFilterPrefix(prefix);
+ // Track which path we expect to display
+ const normalizedDirPath = dirPath || "/";
+ expectedPathRef.current = normalizedDirPath;
+
// Load the directory
loadDirectory(dirPath).then((dir) => {
- if (dir) {
+ // Only update if this is still the path we want
+ if (dir && expectedPathRef.current === normalizedDirPath) {
setDisplayDir(dir);
setError(null);
}
@@ -313,9 +324,21 @@ function DirectoryPickerModal({
{/* Current directory indicator */}
{displayDir && (
- <div className="directory-picker-current">
- {displayDir.path}
- {filterPrefix && <span className="directory-picker-filter">/{filterPrefix}*</span>}
+ <div
+ className={`directory-picker-current${displayDir.git_head_subject ? " directory-picker-current-git" : ""}`}
+ >
+ <span className="directory-picker-current-path">
+ {displayDir.path}
+ {filterPrefix && <span className="directory-picker-filter">/{filterPrefix}*</span>}
+ </span>
+ {displayDir.git_head_subject && (
+ <span
+ className="directory-picker-current-subject"
+ title={displayDir.git_head_subject}
+ >
+ {displayDir.git_head_subject}
+ </span>
+ )}
</div>
)}
@@ -360,7 +383,7 @@ function DirectoryPickerModal({
{filteredEntries.map((entry) => (
<button
key={entry.name}
- className="directory-picker-entry"
+ className={`directory-picker-entry${entry.git_head_subject ? " directory-picker-entry-git" : ""}`}
onClick={() => handleEntryClick(entry)}
>
<svg
@@ -376,7 +399,7 @@ function DirectoryPickerModal({
d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"
/>
</svg>
- <span>
+ <span className="directory-picker-entry-name">
{filterPrefix &&
entry.name.toLowerCase().startsWith(filterPrefix.toLowerCase()) ? (
<>
@@ -387,6 +410,11 @@ function DirectoryPickerModal({
entry.name
)}
</span>
+ {entry.git_head_subject && (
+ <span className="directory-picker-git-subject" title={entry.git_head_subject}>
+ {entry.git_head_subject}
+ </span>
+ )}
</button>
))}