mirror of
https://github.com/aaif-goose/goose.git
synced 2026-07-03 14:10:03 +02:00
Replace artifact files tab with project tree (#165)
This commit is contained in:
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}}"
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user