Replace artifact files tab with project tree (#165)

This commit is contained in:
tulsi
2026-04-13 12:30:34 -07:00
committed by Jack Amadeo
parent eb3c97f8c0
commit daa64aa00b
13 changed files with 726 additions and 359 deletions
+162 -2
View File
@@ -1,13 +1,23 @@
use serde::Serialize;
use tauri::Window;
use tauri_plugin_dialog::DialogExt;
use std::collections::HashSet;
use std::path::PathBuf;
use std::fs;
use std::path::{Path, PathBuf};
const DEFAULT_FILE_MENTION_LIMIT: usize = 1500;
const MAX_FILE_MENTION_LIMIT: usize = 5000;
const MAX_SCAN_DEPTH: usize = 8;
#[derive(Serialize, Clone, Debug, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct FileTreeEntry {
pub name: String,
pub path: String,
pub kind: String,
}
#[tauri::command]
pub fn get_home_dir() -> Result<String, String> {
let home_dir = dirs::home_dir().ok_or("Could not determine home directory")?;
@@ -55,6 +65,66 @@ pub fn path_exists(path: String) -> bool {
std::path::Path::new(&path).exists()
}
fn read_directory_entries(path: &Path) -> Result<Vec<FileTreeEntry>, String> {
if !path.exists() {
return Err(format!("Directory does not exist: {}", path.display()));
}
let metadata = fs::metadata(path)
.map_err(|error| format!("Failed to inspect '{}': {}", path.display(), error))?;
if !metadata.is_dir() {
return Err(format!("Path is not a directory: {}", path.display()));
}
let mut entries = Vec::new();
let reader = fs::read_dir(path)
.map_err(|error| format!("Failed to read directory '{}': {}", path.display(), error))?;
for entry in reader {
let entry = entry
.map_err(|error| format!("Failed to read directory '{}': {}", path.display(), error))?;
let name = entry.file_name().to_string_lossy().into_owned();
if name == ".git" {
continue;
}
let file_type = entry.file_type().map_err(|error| {
format!(
"Failed to inspect directory entry '{}' in '{}': {}",
name,
path.display(),
error
)
})?;
entries.push(FileTreeEntry {
name,
path: entry.path().to_string_lossy().into_owned(),
kind: if file_type.is_dir() {
"directory".to_string()
} else {
"file".to_string()
},
});
}
entries.sort_by(|a, b| {
let a_rank = if a.kind == "directory" { 0 } else { 1 };
let b_rank = if b.kind == "directory" { 0 } else { 1 };
a_rank
.cmp(&b_rank)
.then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
.then_with(|| a.name.cmp(&b.name))
});
Ok(entries)
}
#[tauri::command]
pub fn list_directory_entries(path: String) -> Result<Vec<FileTreeEntry>, String> {
read_directory_entries(Path::new(&path))
}
fn normalize_roots(roots: Vec<String>) -> Vec<PathBuf> {
let mut dedup = HashSet::new();
let mut normalized = Vec::new();
@@ -147,8 +217,10 @@ pub async fn list_files_for_mentions(
#[cfg(test)]
mod tests {
use super::scan_files_for_mentions;
use super::{read_directory_entries, scan_files_for_mentions};
use std::fs;
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
use std::process::Command;
use tempfile::tempdir;
@@ -200,4 +272,92 @@ mod tests {
assert!(joined.contains("visible.ts"));
assert!(!joined.contains(".hidden"));
}
#[test]
fn lists_directory_entries_with_expected_sorting_and_visibility() {
let dir = tempdir().expect("tempdir");
let root = dir.path();
fs::create_dir_all(root.join(".git")).expect(".git dir");
fs::create_dir_all(root.join(".github")).expect(".github dir");
fs::create_dir_all(root.join("node_modules")).expect("node_modules dir");
fs::create_dir_all(root.join("src")).expect("src dir");
fs::write(root.join(".env"), "").expect(".env");
fs::write(root.join(".gitignore"), "node_modules/\n").expect(".gitignore");
fs::write(root.join("README.md"), "").expect("README");
fs::write(root.join("alpha.ts"), "").expect("alpha");
let entries = read_directory_entries(root).expect("entries");
assert_eq!(
entries,
vec![
super::FileTreeEntry {
name: ".github".into(),
path: root.join(".github").to_string_lossy().into_owned(),
kind: "directory".into(),
},
super::FileTreeEntry {
name: "node_modules".into(),
path: root.join("node_modules").to_string_lossy().into_owned(),
kind: "directory".into(),
},
super::FileTreeEntry {
name: "src".into(),
path: root.join("src").to_string_lossy().into_owned(),
kind: "directory".into(),
},
super::FileTreeEntry {
name: ".env".into(),
path: root.join(".env").to_string_lossy().into_owned(),
kind: "file".into(),
},
super::FileTreeEntry {
name: ".gitignore".into(),
path: root.join(".gitignore").to_string_lossy().into_owned(),
kind: "file".into(),
},
super::FileTreeEntry {
name: "alpha.ts".into(),
path: root.join("alpha.ts").to_string_lossy().into_owned(),
kind: "file".into(),
},
super::FileTreeEntry {
name: "README.md".into(),
path: root.join("README.md").to_string_lossy().into_owned(),
kind: "file".into(),
},
]
);
}
#[test]
fn list_directory_entries_errors_for_missing_paths() {
let dir = tempdir().expect("tempdir");
let missing = dir.path().join("missing");
let error = read_directory_entries(&missing).expect_err("missing dir should error");
assert!(error.contains("Directory does not exist"));
}
#[test]
#[cfg(unix)]
fn list_directory_entries_errors_for_unreadable_directories() {
let dir = tempdir().expect("tempdir");
let blocked = dir.path().join("blocked");
fs::create_dir(&blocked).expect("blocked dir");
let original_permissions = fs::metadata(&blocked).expect("metadata").permissions();
let mut unreadable_permissions = original_permissions.clone();
unreadable_permissions.set_mode(0o000);
fs::set_permissions(&blocked, unreadable_permissions).expect("set unreadable");
let error = read_directory_entries(&blocked).expect_err("unreadable dir should error");
let mut restored_permissions = original_permissions;
restored_permissions.set_mode(0o700);
fs::set_permissions(&blocked, restored_permissions).expect("restore permissions");
assert!(error.contains("Failed to read directory"));
}
}
+1
View File
@@ -100,6 +100,7 @@ pub fn run() {
commands::system::get_home_dir,
commands::system::save_exported_session_file,
commands::system::path_exists,
commands::system::list_directory_entries,
commands::system::list_files_for_mentions,
])
.setup(|_app| Ok(()))
@@ -223,7 +223,7 @@ export function ContextPanel({
</TabsContent>
<TabsContent value="files" className="flex-1 overflow-y-auto">
<FilesList />
<FilesList projectWorkingDirs={projectWorkingDirs} />
</TabsContent>
</Tabs>
);
+251 -200
View File
@@ -1,237 +1,288 @@
import { useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { openPath } from "@tauri-apps/plugin-opener";
import {
File,
FileCode,
FileImage,
FileJson,
FileSpreadsheet,
FileText,
FolderOpen,
} from "lucide-react";
import { SearchBar } from "@/shared/ui/SearchBar";
import {
useArtifactPolicyContext,
type SessionArtifact,
} from "../hooks/ArtifactPolicyContext";
FileTree,
FileTreeFile,
FileTreeFolder,
} from "@/shared/ui/ai-elements/file-tree";
import { listDirectoryEntries, type FileTreeEntry } from "@/shared/api/system";
const ICON_CLASS = "h-5 w-5 shrink-0 text-muted-foreground";
interface FilesListProps {
projectWorkingDirs?: string[];
}
const CODE_EXTENSIONS = new Set([
".ts",
".tsx",
".js",
".jsx",
".mjs",
".cjs",
".css",
".scss",
".less",
".sass",
".html",
".htm",
".xml",
".svg",
".py",
".rb",
".rs",
".go",
".java",
".kt",
".c",
".cpp",
".h",
".hpp",
".cs",
".sh",
".bash",
".zsh",
".fish",
".sql",
".graphql",
".gql",
".toml",
".yaml",
".yml",
]);
interface DirectoryState {
entries: FileTreeEntry[];
error: string | null;
status: "idle" | "loading" | "loaded" | "error";
}
const SPREADSHEET_EXTENSIONS = new Set([
".csv",
".tsv",
".xlsx",
".xls",
".ods",
]);
const EMPTY_ROOTS: string[] = [];
const IMAGE_EXTENSIONS = new Set([
".png",
".jpg",
".jpeg",
".gif",
".webp",
".ico",
".bmp",
".tiff",
".avif",
]);
function basename(path: string): string {
const parts = path.split(/[\\/]+/).filter(Boolean);
return parts[parts.length - 1] ?? path;
}
function getFileIcon(artifact: SessionArtifact) {
if (artifact.kind === "folder") {
return <FolderOpen className={ICON_CLASS} />;
function normalizeRoots(roots: string[]): string[] {
return Array.from(
new Set(roots.map((root) => root.trim()).filter((root) => root.length > 0)),
);
}
function TreeStatusRow({
destructive = false,
text,
}: {
destructive?: boolean;
text: string;
}) {
return (
<div
className={`px-2 py-1 text-xs ${
destructive ? "text-destructive" : "text-muted-foreground"
}`}
>
{text}
</div>
);
}
interface DirectoryNodeProps {
directoryStates: Record<string, DirectoryState>;
entry: FileTreeEntry;
loadDirectory: (path: string) => void;
t: (key: string) => string;
}
function DirectoryNode({
directoryStates,
entry,
loadDirectory,
t,
}: DirectoryNodeProps) {
if (entry.kind === "file") {
return (
<FileTreeFile
path={entry.path}
name={entry.name}
contextMenuPath={entry.path}
title={entry.path}
/>
);
}
const ext = artifact.filename.includes(".")
? `.${artifact.filename.split(".").pop()?.toLowerCase()}`
: "";
if (ext === ".json") return <FileJson className={ICON_CLASS} />;
if (ext === ".md" || ext === ".mdx" || ext === ".txt")
return <FileText className={ICON_CLASS} />;
if (SPREADSHEET_EXTENSIONS.has(ext))
return <FileSpreadsheet className={ICON_CLASS} />;
if (IMAGE_EXTENSIONS.has(ext)) return <FileImage className={ICON_CLASS} />;
if (CODE_EXTENSIONS.has(ext)) return <FileCode className={ICON_CLASS} />;
return <File className={ICON_CLASS} />;
}
function FileRow({
artifact,
onOpenFile,
onOpenDirectory,
}: {
artifact: SessionArtifact;
onOpenFile: (path: string) => void;
onOpenDirectory: (path: string) => void;
}) {
const { t } = useTranslation("chat");
return (
<button
type="button"
className="flex w-full min-w-0 cursor-pointer items-center gap-3 rounded-lg border border-border px-4 py-3 text-left hover:bg-muted"
onClick={() => onOpenFile(artifact.resolvedPath)}
<FileTreeFolder
path={entry.path}
name={entry.name}
contextMenuPath={entry.path}
title={entry.path}
toggleOnSelect
>
<div className="flex shrink-0 items-center">{getFileIcon(artifact)}</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-foreground">
{artifact.filename}
</p>
{/* biome-ignore lint/a11y/useKeyWithClickEvents: directory path opens containing folder */}
{/* biome-ignore lint/a11y/noStaticElementInteractions: interactive span inside button to handle separate click target */}
<span
className="mt-0.5 block truncate text-xs text-muted-foreground hover:underline"
onClick={(e) => {
e.stopPropagation();
onOpenDirectory(artifact.resolvedDirectoryPath);
}}
title={t("files.openFolder", { path: artifact.directoryPath })}
>
{artifact.directoryPath}
</span>
</div>
</button>
<DirectoryChildren
directoryPath={entry.path}
directoryStates={directoryStates}
loadDirectory={loadDirectory}
t={t}
/>
</FileTreeFolder>
);
}
export function FilesList() {
const { t } = useTranslation("chat");
const { getAllSessionArtifacts, openResolvedPath, pathExists } =
useArtifactPolicyContext();
const [filter, setFilter] = useState("");
const [existingPaths, setExistingPaths] = useState<Set<string> | null>(null);
const artifacts = useMemo(
() => getAllSessionArtifacts(),
[getAllSessionArtifacts],
);
function DirectoryChildren({
directoryPath,
directoryStates,
loadDirectory,
t,
}: {
directoryPath: string;
directoryStates: Record<string, DirectoryState>;
loadDirectory: (path: string) => void;
t: (key: string) => string;
}) {
const state = directoryStates[directoryPath];
useEffect(() => {
if (artifacts.length === 0) {
setExistingPaths((current) => {
if (current?.size === 0) {
return current;
}
return new Set<string>();
});
if (!state || state.status === "idle") {
loadDirectory(directoryPath);
}
}, [directoryPath, loadDirectory, state]);
if (!state || state.status === "idle" || state.status === "loading") {
return <TreeStatusRow text={t("files.loading")} />;
}
if (state.status === "error") {
return <TreeStatusRow destructive text={t("files.loadError")} />;
}
if (state.entries.length === 0) {
return <TreeStatusRow text={t("files.folderEmpty")} />;
}
return (
<>
{state.entries.map((entry) => (
<DirectoryNode
key={entry.path}
directoryStates={directoryStates}
entry={entry}
loadDirectory={loadDirectory}
t={t}
/>
))}
</>
);
}
export function FilesList({ projectWorkingDirs }: FilesListProps) {
const { t } = useTranslation("chat");
const roots = useMemo(
() => normalizeRoots(projectWorkingDirs ?? EMPTY_ROOTS),
[projectWorkingDirs],
);
const rootsKey = useMemo(() => roots.join("\n"), [roots]);
const [directoryStates, setDirectoryStates] = useState<
Record<string, DirectoryState>
>({});
const directoryStatesRef = useRef<Record<string, DirectoryState>>({});
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(
() => new Set(roots),
);
const [selectedPath, setSelectedPath] = useState<string>();
const generationRef = useRef(0);
const loadDirectory = useCallback((path: string) => {
const generation = generationRef.current;
const existing = directoryStatesRef.current[path];
if (
existing &&
(existing.status === "loading" || existing.status === "loaded")
) {
return;
}
let cancelled = false;
const paths = artifacts.map((a) => a.resolvedPath);
Promise.all(paths.map((p) => pathExists(p).catch(() => false))).then(
(results) => {
if (cancelled) return;
const existing = new Set<string>();
for (let i = 0; i < paths.length; i++) {
if (results[i]) existing.add(paths[i]);
}
setExistingPaths(existing);
directoryStatesRef.current = {
...directoryStatesRef.current,
[path]: {
entries: [],
error: null,
status: "loading",
},
);
return () => {
cancelled = true;
};
}, [artifacts, pathExists]);
setDirectoryStates(directoryStatesRef.current);
const verifiedArtifacts =
existingPaths === null
? artifacts
: artifacts.filter((a) => existingPaths.has(a.resolvedPath));
void listDirectoryEntries(path)
.then((entries) => {
if (generationRef.current !== generation) {
return;
}
const filteredArtifacts = filter
? verifiedArtifacts.filter((a) => {
const query = filter.toLowerCase();
return (
a.filename.toLowerCase().includes(query) ||
a.directoryPath.toLowerCase().includes(query)
);
directoryStatesRef.current = {
...directoryStatesRef.current,
[path]: {
entries,
error: null,
status: "loaded",
},
};
setDirectoryStates(directoryStatesRef.current);
})
: verifiedArtifacts;
.catch((error: unknown) => {
if (generationRef.current !== generation) {
return;
}
const handleOpenFile = (path: string) => {
void openResolvedPath(path);
};
directoryStatesRef.current = {
...directoryStatesRef.current,
[path]: {
entries: [],
error: error instanceof Error ? error.message : String(error),
status: "error",
},
};
setDirectoryStates(directoryStatesRef.current);
});
}, []);
const handleOpenDirectory = (path: string) => {
void openResolvedPath(path);
};
useEffect(() => {
const nextRoots = rootsKey ? rootsKey.split("\n") : [];
generationRef.current += 1;
directoryStatesRef.current = {};
setDirectoryStates({});
setExpandedPaths(new Set(nextRoots));
setSelectedPath(undefined);
}, [rootsKey]);
if (verifiedArtifacts.length === 0) {
useEffect(() => {
const nextRoots = rootsKey ? rootsKey.split("\n") : [];
for (const root of nextRoots) {
loadDirectory(root);
}
}, [loadDirectory, rootsKey]);
const handleExpandedChange = useCallback(
(nextExpanded: Set<string>) => {
const normalizedExpanded = new Set(nextExpanded);
setExpandedPaths(normalizedExpanded);
for (const path of normalizedExpanded) {
loadDirectory(path);
}
},
[loadDirectory],
);
const handleOpenFile = useCallback((path: string) => {
setSelectedPath(path);
void openPath(path);
}, []);
if (roots.length === 0) {
return (
<div className="flex h-32 items-center justify-center">
<div className="flex h-32 items-center justify-center px-4 text-center">
<p className="text-sm text-muted-foreground">{t("files.empty")}</p>
</div>
);
}
return (
<div className="min-w-0 overflow-hidden">
<div className="px-3 pb-2 pt-3">
<SearchBar
value={filter}
onChange={setFilter}
placeholder={t("files.searchPlaceholder")}
/>
</div>
{filteredArtifacts.length === 0 ? (
<div className="flex h-20 items-center justify-center">
<p className="text-sm text-muted-foreground">
{t("files.noMatches")}
</p>
</div>
) : (
<div className="space-y-2 px-3 pb-3">
{filteredArtifacts.map((artifact) => (
<FileRow
key={artifact.resolvedPath}
artifact={artifact}
onOpenFile={handleOpenFile}
onOpenDirectory={handleOpenDirectory}
/>
))}
</div>
)}
<div className="min-w-0 px-1 pb-1 pt-1">
<FileTree
className="border-0 bg-transparent"
contentClassName="p-1"
expanded={expandedPaths}
onExpandedChange={handleExpandedChange}
onSelect={handleOpenFile}
selectedPath={selectedPath}
>
{roots.map((root) => {
const state = directoryStates[root];
return (
<FileTreeFolder
key={root}
path={root}
name={basename(root)}
contextMenuPath={root}
title={root}
toggleOnSelect
>
{state?.status === "error" ? (
<TreeStatusRow destructive text={t("files.rootLoadError")} />
) : (
<DirectoryChildren
directoryPath={root}
directoryStates={directoryStates}
loadDirectory={loadDirectory}
t={t}
/>
)}
</FileTreeFolder>
);
})}
</FileTree>
</div>
);
}
@@ -4,9 +4,17 @@ import userEvent from "@testing-library/user-event";
import * as gitApi from "@/shared/api/git";
import { ContextPanel } from "../ContextPanel";
const mockUseGitState = vi.fn();
const mockRefetch = vi.fn();
const mockRefetchFiles = vi.fn();
const {
mockUseGitState,
mockRefetch,
mockRefetchFiles,
mockListDirectoryEntries,
} = vi.hoisted(() => ({
mockUseGitState: vi.fn(),
mockRefetch: vi.fn(),
mockRefetchFiles: vi.fn(),
mockListDirectoryEntries: vi.fn(),
}));
vi.mock("@/shared/hooks/useGitState", () => ({
useGitState: (...args: unknown[]) => mockUseGitState(...args),
@@ -28,6 +36,10 @@ vi.mock("@tauri-apps/plugin-opener", () => ({
openPath: vi.fn(),
}));
vi.mock("@/shared/api/system", () => ({
listDirectoryEntries: mockListDirectoryEntries,
}));
vi.mock("@/shared/api/git", () => ({
createBranch: vi.fn(),
createWorktree: vi.fn(),
@@ -68,6 +80,7 @@ describe("ContextPanel", () => {
vi.clearAllMocks();
mockRefetch.mockResolvedValue(undefined);
mockRefetchFiles.mockResolvedValue(undefined);
mockListDirectoryEntries.mockResolvedValue([]);
vi.mocked(gitApi.createWorktree).mockResolvedValue({
path: "/Users/test/goose2-worktrees/new-worktree",
branch: "new-worktree",
@@ -131,7 +144,7 @@ describe("ContextPanel", () => {
await user.click(screen.getByRole("tab", { name: /files/i }));
expect(screen.getByText("No files yet")).toBeInTheDocument();
expect(screen.getByText("goose2")).toBeInTheDocument();
});
it("shows path and init button for non-git directory", async () => {
@@ -1,119 +1,163 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { FilesList } from "../FilesList";
const mockGetAllSessionArtifacts = vi.fn();
const mockOpenResolvedPath = vi.fn();
const mockPathExists = vi.fn();
const { mockListDirectoryEntries, mockOpenPath, mockRevealInFileManager } =
vi.hoisted(() => ({
mockListDirectoryEntries: vi.fn(),
mockOpenPath: vi.fn(),
mockRevealInFileManager: vi.fn(),
}));
vi.mock("../../hooks/ArtifactPolicyContext", () => ({
useArtifactPolicyContext: () => ({
getAllSessionArtifacts: mockGetAllSessionArtifacts,
openResolvedPath: mockOpenResolvedPath,
pathExists: mockPathExists,
}),
vi.mock("@/shared/api/system", () => ({
listDirectoryEntries: mockListDirectoryEntries,
}));
const makeArtifact = (overrides: Record<string, unknown> = {}) => ({
resolvedPath: "/Users/test/project/src/App.tsx",
resolvedDirectoryPath: "/Users/test/project/src/",
displayPath: "~/project/src/App.tsx",
filename: "App.tsx",
directoryPath: "~/project/src/",
versionCount: 1,
lastTouchedAt: 1000,
vi.mock("@tauri-apps/plugin-opener", () => ({
openPath: mockOpenPath,
}));
vi.mock("@/shared/lib/fileManager", () => ({
revealInFileManager: mockRevealInFileManager,
}));
const makeEntry = (overrides: Record<string, unknown> = {}) => ({
kind: "file" as const,
toolName: "Write",
name: "README.md",
path: "/Users/test/project/README.md",
...overrides,
});
describe("FilesList", () => {
beforeEach(() => {
vi.clearAllMocks();
mockOpenResolvedPath.mockResolvedValue(undefined);
mockPathExists.mockResolvedValue(true);
mockOpenPath.mockResolvedValue(undefined);
mockRevealInFileManager.mockResolvedValue(undefined);
mockListDirectoryEntries.mockResolvedValue([]);
});
it("shows empty state when no artifacts", () => {
mockGetAllSessionArtifacts.mockReturnValue([]);
it("shows an empty state when no project working directories are available", () => {
render(<FilesList />);
expect(screen.getByText("No files yet")).toBeInTheDocument();
expect(
screen.getByText(
"Project files are unavailable until a project with working directories is assigned.",
),
).toBeInTheDocument();
});
it("renders file rows with filename and directory", async () => {
mockGetAllSessionArtifacts.mockReturnValue([makeArtifact()]);
render(<FilesList />);
it("renders separate top-level roots for each working directory", async () => {
render(
<FilesList
projectWorkingDirs={["/Users/test/goose2", "/Users/test/sprout"]}
/>,
);
await waitFor(() => {
expect(screen.getByText("App.tsx")).toBeInTheDocument();
expect(mockListDirectoryEntries).toHaveBeenCalledWith(
"/Users/test/goose2",
);
expect(mockListDirectoryEntries).toHaveBeenCalledWith(
"/Users/test/sprout",
);
});
expect(screen.getByText("~/project/src/")).toBeInTheDocument();
expect(screen.getByText("goose2")).toBeInTheDocument();
expect(screen.getByText("sprout")).toBeInTheDocument();
});
it("hides files that do not exist on disk", async () => {
mockGetAllSessionArtifacts.mockReturnValue([
makeArtifact({ filename: "exists.tsx", resolvedPath: "/a/exists.tsx" }),
makeArtifact({ filename: "gone.tsx", resolvedPath: "/a/gone.tsx" }),
]);
mockPathExists.mockImplementation((path: string) =>
Promise.resolve(path === "/a/exists.tsx"),
);
render(<FilesList />);
it("expands folders in place without opening them externally", async () => {
const user = userEvent.setup();
mockListDirectoryEntries.mockImplementation((path: string) => {
if (path === "/Users/test/project") {
return Promise.resolve([
makeEntry({
kind: "directory",
name: "src",
path: "/Users/test/project/src",
}),
]);
}
if (path === "/Users/test/project/src") {
return Promise.resolve([
makeEntry({
name: "App.tsx",
path: "/Users/test/project/src/App.tsx",
}),
]);
}
return Promise.resolve([]);
});
render(<FilesList projectWorkingDirs={["/Users/test/project"]} />);
await screen.findByText("src");
await user.click(screen.getByText("src"));
await waitFor(() => {
expect(screen.getByText("exists.tsx")).toBeInTheDocument();
expect(mockListDirectoryEntries).toHaveBeenCalledWith(
"/Users/test/project/src",
);
});
expect(screen.queryByText("gone.tsx")).not.toBeInTheDocument();
expect(mockOpenPath).not.toHaveBeenCalled();
expect(screen.getByText("App.tsx")).toBeInTheDocument();
});
it("calls openResolvedPath with file path when row is clicked", async () => {
it("opens files externally when a file is clicked", async () => {
const user = userEvent.setup();
mockGetAllSessionArtifacts.mockReturnValue([makeArtifact()]);
render(<FilesList />);
await user.click(screen.getByText("App.tsx"));
expect(mockOpenResolvedPath).toHaveBeenCalledWith(
"/Users/test/project/src/App.tsx",
);
});
it("calls openResolvedPath with directory when directory path is clicked", async () => {
const user = userEvent.setup();
mockGetAllSessionArtifacts.mockReturnValue([makeArtifact()]);
render(<FilesList />);
await user.click(screen.getByText("~/project/src/"));
expect(mockOpenResolvedPath).toHaveBeenCalledWith(
"/Users/test/project/src/",
);
});
it("filters files by filename", async () => {
const user = userEvent.setup();
mockGetAllSessionArtifacts.mockReturnValue([
makeArtifact({ filename: "App.tsx", resolvedPath: "/a/App.tsx" }),
makeArtifact({ filename: "index.ts", resolvedPath: "/a/index.ts" }),
mockListDirectoryEntries.mockResolvedValue([
makeEntry({
name: "README.md",
path: "/Users/test/project/README.md",
}),
]);
render(<FilesList />);
const input = screen.getByPlaceholderText("Search");
await user.type(input, "index");
render(<FilesList projectWorkingDirs={["/Users/test/project"]} />);
expect(screen.queryByText("App.tsx")).not.toBeInTheDocument();
expect(screen.getByText("index.ts")).toBeInTheDocument();
await user.click(await screen.findByText("README.md"));
expect(mockOpenPath).toHaveBeenCalledWith("/Users/test/project/README.md");
});
it("shows no matching files message when filter has no results", async () => {
it("supports context menu actions for folders and files", async () => {
const user = userEvent.setup();
mockGetAllSessionArtifacts.mockReturnValue([makeArtifact()]);
render(<FilesList />);
mockListDirectoryEntries.mockResolvedValue([
makeEntry({
kind: "directory",
name: "src",
path: "/Users/test/project/src",
}),
makeEntry({
name: "README.md",
path: "/Users/test/project/README.md",
}),
]);
const input = screen.getByPlaceholderText("Search");
await user.type(input, "nonexistent");
render(<FilesList projectWorkingDirs={["/Users/test/project"]} />);
expect(screen.getByText("No matching files")).toBeInTheDocument();
});
const folderLabel = await screen.findByText("src");
fireEvent.contextMenu(folderLabel);
await user.click(
screen.getByRole("menuitem", {
name: /reveal in (finder|explorer|file manager)/i,
}),
);
expect(mockRevealInFileManager).toHaveBeenCalledWith(
"/Users/test/project/src",
);
it("does not show search bar when no artifacts exist", () => {
mockGetAllSessionArtifacts.mockReturnValue([]);
render(<FilesList />);
expect(screen.queryByPlaceholderText("Search")).not.toBeInTheDocument();
const fileLabel = screen.getByText("README.md");
fireEvent.contextMenu(fileLabel);
await user.click(
screen.getByRole("menuitem", {
name: /reveal in (finder|explorer|file manager)/i,
}),
);
expect(mockRevealInFileManager).toHaveBeenCalledWith(
"/Users/test/project/README.md",
);
});
});
@@ -123,7 +123,7 @@ export function ArtifactsWidget() {
return (
<FileContextMenu
key={artifact.resolvedPath}
filePath={artifact.resolvedPath}
path={artifact.resolvedPath}
>
<button
type="button"
@@ -60,7 +60,7 @@ function ChangedFileRow({
if (isDeleted) return row;
return <FileContextMenu filePath={fullPath}>{row}</FileContextMenu>;
return <FileContextMenu path={fullPath}>{row}</FileContextMenu>;
}
interface ChangesWidgetProps {
+12
View File
@@ -1,5 +1,11 @@
import { invoke } from "@tauri-apps/api/core";
export interface FileTreeEntry {
name: string;
path: string;
kind: "file" | "directory";
}
export async function getHomeDir(): Promise<string> {
return invoke("get_home_dir");
}
@@ -21,3 +27,9 @@ export async function listFilesForMentions(
): Promise<string[]> {
return invoke("list_files_for_mentions", { roots, maxResults });
}
export async function listDirectoryEntries(
path: string,
): Promise<FileTreeEntry[]> {
return invoke("list_directory_entries", { path });
}
@@ -91,10 +91,12 @@
}
},
"files": {
"empty": "No files yet",
"noMatches": "No matching files",
"empty": "Project files are unavailable until a project with working directories is assigned.",
"folderEmpty": "Folder is empty",
"loadError": "Unable to load folder contents.",
"loading": "Loading files...",
"openFolder": "Open folder: {{path}}",
"searchPlaceholder": "Search"
"rootLoadError": "Unable to load this folder."
},
"image": {
"view": "View {{label}}"
@@ -91,10 +91,12 @@
}
},
"files": {
"empty": "Aún no hay archivos",
"noMatches": "No hay archivos que coincidan",
"empty": "Los archivos del proyecto no están disponibles hasta que se asigne un proyecto con directorios de trabajo.",
"folderEmpty": "La carpeta está vacía",
"loadError": "No se pudo cargar el contenido de la carpeta.",
"loading": "Cargando archivos...",
"openFolder": "Abrir carpeta: {{path}}",
"searchPlaceholder": "Buscar"
"rootLoadError": "No se pudo cargar esta carpeta."
},
"image": {
"view": "Ver {{label}}"
+142 -60
View File
@@ -4,12 +4,13 @@ import {
CollapsibleTrigger,
} from "@/shared/ui/collapsible";
import { cn } from "@/shared/lib/cn";
import { FileContextMenu } from "@/shared/ui/file-context-menu";
import {
ChevronRightIcon,
FileIcon,
FolderIcon,
FolderOpenIcon,
} from "lucide-react";
IconChevronRight,
IconFile,
IconFolder,
IconFolderOpen,
} from "@tabler/icons-react";
import type { HTMLAttributes, ReactNode } from "react";
import {
createContext,
@@ -37,6 +38,7 @@ const FileTreeContext = createContext<FileTreeContextType>({
});
export type FileTreeProps = Omit<HTMLAttributes<HTMLDivElement>, "onSelect"> & {
contentClassName?: string;
expanded?: Set<string>;
defaultExpanded?: Set<string>;
selectedPath?: string;
@@ -50,6 +52,7 @@ export const FileTree = ({
selectedPath,
onSelect,
onExpandedChange,
contentClassName,
className,
children,
...props
@@ -80,13 +83,13 @@ export const FileTree = ({
<FileTreeContext.Provider value={contextValue}>
<div
className={cn(
"rounded-lg border bg-background font-mono text-sm",
"rounded-lg border border-border bg-background text-sm text-foreground",
className,
)}
role="tree"
{...props}
>
<div className="p-2">{children}</div>
<div className={cn("p-2", contentClassName)}>{children}</div>
</div>
</FileTreeContext.Provider>
);
@@ -129,13 +132,17 @@ const FileTreeFolderContext = createContext<FileTreeFolderContextType>({
});
export type FileTreeFolderProps = HTMLAttributes<HTMLDivElement> & {
contextMenuPath?: string;
path: string;
name: string;
toggleOnSelect?: boolean;
};
export const FileTreeFolder = ({
contextMenuPath,
path,
name,
toggleOnSelect = false,
className,
children,
...props
@@ -150,8 +157,12 @@ export const FileTreeFolder = ({
}, [togglePath, path]);
const handleSelect = useCallback(() => {
if (toggleOnSelect) {
togglePath(path);
return;
}
onSelect?.(path);
}, [onSelect, path]);
}, [onSelect, path, toggleOnSelect, togglePath]);
const folderContextValue = useMemo(
() => ({ isExpanded, name, path }),
@@ -167,40 +178,79 @@ export const FileTreeFolder = ({
tabIndex={0}
{...props}
>
<div
className={cn(
"flex w-full items-center gap-1 rounded px-2 py-1 text-left transition-colors hover:bg-muted/50",
isSelected && "bg-muted",
)}
>
<CollapsibleTrigger asChild>
{contextMenuPath ? (
<FileContextMenu path={contextMenuPath}>
<div
className={cn(
"flex w-full items-center gap-1 rounded px-2 py-1 text-left transition-colors hover:bg-muted/50",
isSelected && "bg-muted",
)}
>
<CollapsibleTrigger asChild>
<button
className="flex shrink-0 cursor-pointer items-center border-none bg-transparent p-0"
type="button"
>
<IconChevronRight
className={cn(
"size-4 shrink-0 text-muted-foreground transition-transform",
isExpanded && "rotate-90",
)}
/>
</button>
</CollapsibleTrigger>
<button
className="flex min-w-0 flex-1 cursor-pointer items-center gap-1 border-none bg-transparent p-0 text-left"
onClick={handleSelect}
type="button"
>
<FileTreeIcon>
{isExpanded ? (
<IconFolderOpen className="size-4 text-muted-foreground" />
) : (
<IconFolder className="size-4 text-muted-foreground" />
)}
</FileTreeIcon>
<FileTreeName>{name}</FileTreeName>
</button>
</div>
</FileContextMenu>
) : (
<div
className={cn(
"flex w-full items-center gap-1 rounded px-2 py-1 text-left transition-colors hover:bg-muted/50",
isSelected && "bg-muted",
)}
>
<CollapsibleTrigger asChild>
<button
className="flex shrink-0 cursor-pointer items-center border-none bg-transparent p-0"
type="button"
>
<IconChevronRight
className={cn(
"size-4 shrink-0 text-muted-foreground transition-transform",
isExpanded && "rotate-90",
)}
/>
</button>
</CollapsibleTrigger>
<button
className="flex shrink-0 cursor-pointer items-center border-none bg-transparent p-0"
className="flex min-w-0 flex-1 cursor-pointer items-center gap-1 border-none bg-transparent p-0 text-left"
onClick={handleSelect}
type="button"
>
<ChevronRightIcon
className={cn(
"size-4 shrink-0 text-muted-foreground transition-transform",
isExpanded && "rotate-90",
<FileTreeIcon>
{isExpanded ? (
<IconFolderOpen className="size-4 text-muted-foreground" />
) : (
<IconFolder className="size-4 text-muted-foreground" />
)}
/>
</FileTreeIcon>
<FileTreeName>{name}</FileTreeName>
</button>
</CollapsibleTrigger>
<button
className="flex min-w-0 flex-1 cursor-pointer items-center gap-1 border-none bg-transparent p-0 text-left"
onClick={handleSelect}
type="button"
>
<FileTreeIcon>
{isExpanded ? (
<FolderOpenIcon className="size-4 text-blue-500" />
) : (
<FolderIcon className="size-4 text-blue-500" />
)}
</FileTreeIcon>
<FileTreeName>{name}</FileTreeName>
</button>
</div>
</div>
)}
<CollapsibleContent>
<div className="ml-4 border-l pl-2">{children}</div>
</CollapsibleContent>
@@ -221,12 +271,14 @@ const FileTreeFileContext = createContext<FileTreeFileContextType>({
});
export type FileTreeFileProps = HTMLAttributes<HTMLDivElement> & {
contextMenuPath?: string;
path: string;
name: string;
icon?: ReactNode;
};
export const FileTreeFile = ({
contextMenuPath,
path,
name,
icon,
@@ -254,29 +306,59 @@ export const FileTreeFile = ({
return (
<FileTreeFileContext.Provider value={fileContextValue}>
<div
className={cn(
"flex cursor-pointer items-center gap-1 rounded px-2 py-1 transition-colors hover:bg-muted/50",
isSelected && "bg-muted",
className,
)}
onClick={handleClick}
onKeyDown={handleKeyDown}
role="treeitem"
tabIndex={0}
{...props}
>
{children ?? (
<>
{/* Spacer for alignment */}
<span className="size-4 shrink-0" />
<FileTreeIcon>
{icon ?? <FileIcon className="size-4 text-muted-foreground" />}
</FileTreeIcon>
<FileTreeName>{name}</FileTreeName>
</>
)}
</div>
{contextMenuPath ? (
<FileContextMenu path={contextMenuPath}>
<div
className={cn(
"flex cursor-pointer items-center gap-1 rounded px-2 py-1 transition-colors hover:bg-muted/50",
isSelected && "bg-muted",
className,
)}
onClick={handleClick}
onKeyDown={handleKeyDown}
role="treeitem"
tabIndex={0}
{...props}
>
{children ?? (
<>
{/* Spacer for alignment */}
<span className="size-4 shrink-0" />
<FileTreeIcon>
{icon ?? (
<IconFile className="size-4 text-muted-foreground" />
)}
</FileTreeIcon>
<FileTreeName>{name}</FileTreeName>
</>
)}
</div>
</FileContextMenu>
) : (
<div
className={cn(
"flex cursor-pointer items-center gap-1 rounded px-2 py-1 transition-colors hover:bg-muted/50",
isSelected && "bg-muted",
className,
)}
onClick={handleClick}
onKeyDown={handleKeyDown}
role="treeitem"
tabIndex={0}
{...props}
>
{children ?? (
<>
{/* Spacer for alignment */}
<span className="size-4 shrink-0" />
<FileTreeIcon>
{icon ?? <IconFile className="size-4 text-muted-foreground" />}
</FileTreeIcon>
<FileTreeName>{name}</FileTreeName>
</>
)}
</div>
)}
</FileTreeFileContext.Provider>
);
};
@@ -12,11 +12,11 @@ import { getPlatform } from "@/shared/lib/platform";
const revealLabel = `labels.revealInFileManager_${getPlatform()}` as const;
interface FileContextMenuProps {
filePath: string;
path: string;
children: ReactNode;
}
export function FileContextMenu({ filePath, children }: FileContextMenuProps) {
export function FileContextMenu({ path, children }: FileContextMenuProps) {
const { t } = useTranslation("common");
return (
@@ -24,11 +24,11 @@ export function FileContextMenu({ filePath, children }: FileContextMenuProps) {
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem
onSelect={() => void navigator.clipboard.writeText(filePath)}
onSelect={() => void navigator.clipboard.writeText(path)}
>
{t("labels.copyPath")}
</ContextMenuItem>
<ContextMenuItem onSelect={() => void revealInFileManager(filePath)}>
<ContextMenuItem onSelect={() => void revealInFileManager(path)}>
{t(revealLabel)}
</ContextMenuItem>
</ContextMenuContent>