From 3e4daeb53b673297d249ab89c780ec5d24345e11 Mon Sep 17 00:00:00 2001 From: Daniel Schwarz Date: Wed, 15 Jul 2026 11:35:54 +0200 Subject: [PATCH] feat: browse files at selected commit --- README.md | 3 +- ROADMAP.md | 8 ++ TASKS.md | 7 +- crates/strand-core/src/tree.rs | 122 ++++++++++++++++++++++++++-- crates/strand-tauri/src/commands.rs | 8 ++ crates/strand-tauri/src/main.rs | 1 + ui/src/components/Sidebar.tsx | 68 +++++++++++----- ui/src/lib/tauri.ts | 2 + ui/src/stores/repo.ts | 9 +- ui/src/styles/features.css | 11 +++ ui/src/views/FileView.tsx | 59 ++++++++++---- website/docs/commits-and-history.md | 6 +- website/docs/getting-started.md | 4 +- 13 files changed, 257 insertions(+), 51 deletions(-) diff --git a/README.md b/README.md index 8b48191..c7b7f5b 100644 --- a/README.md +++ b/README.md @@ -116,7 +116,8 @@ keyboard alone, and the mouse stays first-class. files, commits, and recent repos, with scope filtering and full keyboard + screen-reader operability. - **File view** — highlighted source, `--follow` history, compare any two - revisions, blame, and rendered previews for markdown and SVG. + revisions, blame, and rendered previews for markdown and SVG; selecting a + commit re-roots the Files tree and pins opened content to that revision. - **Comfortable to live in** — multiple repositories open at once (as a vertical icon rail or horizontal toolbar tabs, your pick in Appearance) persisted across launches, saveable as named **workspaces** (the repos behind diff --git a/ROADMAP.md b/ROADMAP.md index 089a6aa..723a25e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1058,6 +1058,8 @@ passes on its measured platform. Doc-only change: PRD §8, `docs/perf-baseline.m base commit; distinct diamond marker + `stash@{n}` chip; right-click Apply/Pop/Drop) - ☑ Drag-and-drop renames in file tree — shipped 2026-07-06 (see changelog entry below) +- ☑ File tree re-roots to the selected commit; historical Content/Preview reads + stay pinned to that revision — shipped 2026-07-15 - ☑ Crash reporting (opt-in, off by default) — user-mediated GitHub-issue flow, shipped 2026-07-06 (see changelog entry below) - ◐ Hosted pull-request workspace — GitHub + Azure DevOps list/detail, @@ -1651,6 +1653,12 @@ mounted Pierre diff, file selection, scroll, focus, and per-thread drafts. Coordinate-based new comments retain their exact-head guard; replies and thread state do not need one because they target an existing stable thread. +**Revision file tree shipped (2026-07-15):** Selecting a commit now re-roots +the sidebar Files tab through `Repo::tree_at` / `repo_tree_at`, with a visible +short-hash context and read-only tree actions. Opening a historical file pins +Content, image/SVG previews, Markdown, and repo-relative Markdown images to the +same revision instead of accidentally reading the mutable working tree. + --- ## 1.1+ — Post-1.0 diff --git a/TASKS.md b/TASKS.md index c89a562..8e45c07 100644 --- a/TASKS.md +++ b/TASKS.md @@ -56,7 +56,8 @@ Legend: ☐ not started · ◐ in progress · ☑ done · ✗ blocked rename-following with per-path add/del counts) - ☑ Merge base of two commit-ishes (`Repo::merge_base` in `refs.rs` — git2 `revparse_single` + `merge_base`; powers the worktree review-vs-base baseline) -- ☐ Tree listing for a commit (powers file tree at a revision) +- ☑ Tree listing for a commit (`Repo::tree_at` + `repo_tree_at`, path-sorted + blob/gitlink walk with focused revision tests) - ☑ File content at a revision (`Repo::file_content` — working tree from disk via `safe_workdir_path`, or a blob at a revision; binary heuristic + 2 MB cap) - ☑ Raw file blob at worktree / index / revision (`Repo::file_blob` in `file.rs` — @@ -256,6 +257,7 @@ Legend: ☐ not started · ◐ in progress · ☑ done · ✗ blocked - ☑ Read commands: `repo_open`, `repo_meta`, `repo_status`, `repo_log`, `repo_search_log`, `repo_refs`, `repo_diff_unstaged` / `_staged` / `_between`, `repo_tree`, + `repo_tree_at`, `repo_submodules`, `repo_blame`, `repo_reflog`, `repo_file_content`, `repo_file_blob`, `repo_file_history`, `repo_diff_commit_file`, `repo_merge_base` @@ -773,7 +775,8 @@ Legend: ☐ not started · ◐ in progress · ☑ done · ✗ blocked - ☑ Right-click a graph row → `ContextMenu` with the same actions (Checkout / Tag… / Cherry-pick / Revert / Copy SHA); keyboard-operable via Menu key / Shift+F10 on the focused row (opens at the row corner) -- ☐ Files tab re-roots to the selected commit (PRD §6.2 — needs `repo_tree_at`) +- ☑ Files tab re-roots to the selected commit (`repo_tree_at` feeds a read-only + Pierre tree; opened Content/Preview files stay pinned to that revision) - ☑ Search bar — wired in the All Commits header (`Commits.tsx`). A field picker (Message / Author / Hash, via `ContextMenu`) + text input highlight matching rows **in place** (`.match` wash + accent-bolded substring) without filtering, diff --git a/crates/strand-core/src/tree.rs b/crates/strand-core/src/tree.rs index 3ed4b89..a99629e 100644 --- a/crates/strand-core/src/tree.rs +++ b/crates/strand-core/src/tree.rs @@ -1,9 +1,10 @@ -//! Working-tree file listing — powers the Files sidebar tab. +//! Repository file listings — powers the Files sidebar tab. //! -//! Lists every tracked file (from the index) plus untracked-but-not-ignored -//! files, each tagged with its change status so the UI can paint a badge. -//! The frontend groups the flat list into a folder tree (it already does the -//! same for the Local Changes and Branches trees). +//! The working-tree path lists every tracked file (from the index) plus +//! untracked-but-not-ignored files, each tagged with its change status so the +//! UI can paint a badge. The revision path walks a commit tree and returns the +//! immutable file set at that point in history. The frontend groups either +//! flat list into a folder tree. use std::collections::BTreeMap; @@ -27,6 +28,33 @@ impl Repo { let statuses = repo.statuses(Some(&mut crate::status::status_options()))?; from_index_and_statuses(repo, &statuses) } + + /// List every file present at `rev`. Revision entries are immutable and + /// therefore carry no working-tree status badge. + pub fn tree_at(&self, rev: &str) -> Result> { + let repo = self.git2()?; + let tree = repo.revparse_single(rev)?.peel_to_commit()?.tree()?; + let mut entries = Vec::new(); + + tree.walk(git2::TreeWalkMode::PreOrder, |root, entry| { + let is_file = matches!( + entry.kind(), + Some(git2::ObjectType::Blob | git2::ObjectType::Commit) + ); + if is_file { + if let Some(name) = entry.name() { + entries.push(WorkTreeEntry { + path: format!("{root}{name}"), + status: None, + }); + } + } + git2::TreeWalkResult::Ok + })?; + + entries.sort_unstable_by(|a, b| a.path.cmp(&b.path)); + Ok(entries) + } } /// Build the work-tree listing from an already-open repo and an already-run @@ -85,3 +113,87 @@ fn classify(s: git2::Status) -> StatusKind { } StatusKind::Modified } + +#[cfg(test)] +mod tests { + use super::*; + use std::path::Path; + + fn scratch_repo() -> (Repo, std::path::PathBuf) { + let dir = std::env::temp_dir().join(format!( + "strand-tree-test-{}-{:?}", + std::process::id(), + std::thread::current().id() + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let git = git2::Repository::init(&dir).unwrap(); + { + let mut cfg = git.config().unwrap(); + cfg.set_str("user.name", "Test").unwrap(); + cfg.set_str("user.email", "test@example.com").unwrap(); + } + (Repo::discover(&dir).unwrap(), dir) + } + + fn commit_all(repo: &Repo, message: &str) -> String { + let git = repo.git2().unwrap(); + let mut index = git.index().unwrap(); + index + .add_all(["*"], git2::IndexAddOption::DEFAULT, None) + .unwrap(); + index.write().unwrap(); + let tree_oid = index.write_tree().unwrap(); + let tree = git.find_tree(tree_oid).unwrap(); + let sig = git2::Signature::now("Test", "test@example.com").unwrap(); + let parent = git.head().ok().and_then(|h| h.peel_to_commit().ok()); + let parents: Vec<&git2::Commit<'_>> = parent.iter().collect(); + git.commit(Some("HEAD"), &sig, &sig, message, &tree, &parents) + .unwrap() + .to_string() + } + + #[test] + fn tree_at_lists_the_selected_revision_only() { + let (repo, dir) = scratch_repo(); + std::fs::create_dir_all(dir.join("src/nested")).unwrap(); + std::fs::write(dir.join("README.md"), "first\n").unwrap(); + std::fs::write(dir.join("src/nested/a.rs"), "fn a() {}\n").unwrap(); + let first = commit_all(&repo, "first"); + + std::fs::remove_file(dir.join("README.md")).unwrap(); + std::fs::write(dir.join("src/b.rs"), "fn b() {}\n").unwrap(); + let second = commit_all(&repo, "second"); + + assert_eq!( + repo.tree_at(&first) + .unwrap() + .into_iter() + .map(|e| (e.path, e.status)) + .collect::>(), + vec![("README.md".into(), None), ("src/nested/a.rs".into(), None),] + ); + assert_eq!( + repo.tree_at(&second) + .unwrap() + .into_iter() + .map(|e| e.path) + .collect::>(), + vec!["src/b.rs", "src/nested/a.rs"] + ); + + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn tree_at_accepts_refs_and_rejects_unknown_revisions() { + let (repo, dir) = scratch_repo(); + std::fs::write(dir.join(Path::new("a.txt")), "a\n").unwrap(); + commit_all(&repo, "first"); + + assert_eq!(repo.tree_at("HEAD").unwrap()[0].path, "a.txt"); + assert!(repo.tree_at("does-not-exist").is_err()); + + let _ = std::fs::remove_dir_all(dir); + } +} diff --git a/crates/strand-tauri/src/commands.rs b/crates/strand-tauri/src/commands.rs index 491310d..f81c8d3 100644 --- a/crates/strand-tauri/src/commands.rs +++ b/crates/strand-tauri/src/commands.rs @@ -696,6 +696,14 @@ pub async fn repo_tree(path: String) -> CmdResult> { run_blocking("tree", move || Ok(Repo::discover(&path)?.work_tree()?)).await } +#[tauri::command(async)] +pub async fn repo_tree_at(path: String, rev: String) -> CmdResult> { + run_blocking("tree at revision", move || { + Ok(Repo::discover(&path)?.tree_at(&rev)?) + }) + .await +} + #[tauri::command(async)] pub async fn repo_submodules(path: String) -> CmdResult> { run_blocking("submodules", move || Ok(Repo::discover(&path)?.submodules()?)).await diff --git a/crates/strand-tauri/src/main.rs b/crates/strand-tauri/src/main.rs index a78a497..2651030 100644 --- a/crates/strand-tauri/src/main.rs +++ b/crates/strand-tauri/src/main.rs @@ -116,6 +116,7 @@ fn main() { commands::repo_checkout, commands::repo_checkout_commit, commands::repo_tree, + commands::repo_tree_at, commands::repo_submodules, commands::repo_submodule_update, commands::repo_worktrees, diff --git a/ui/src/components/Sidebar.tsx b/ui/src/components/Sidebar.tsx index 029d743..208187d 100644 --- a/ui/src/components/Sidebar.tsx +++ b/ui/src/components/Sidebar.tsx @@ -165,6 +165,7 @@ export function Sidebar({ onOpenRepo, onOpenRecent, onCreateStash, onCreateTag, const remoteTags = useRepo((s) => s.remoteTags); const refreshRemoteTags = useRepo((s) => s.refreshRemoteTags); const workTree = useRepo((s) => s.workTree); + const selectedCommit = useRepo((s) => s.selectedCommit); const refreshTree = useRepo((s) => s.refreshTree); const gitignoreAdd = useRepo((s) => s.gitignoreAdd); const moveEntries = useRepo((s) => s.moveEntries); @@ -332,36 +333,53 @@ export function Sidebar({ onOpenRepo, onOpenRecent, onCreateStash, onCreateTag, ); }, [submodules, filter]); - // Files tab: lazily fetch the working-tree listing when the tab is shown - // (keyed on `meta?.path` so a tab switch reloads it). Ongoing freshness - // comes from `refreshSnapshot` — every write op and watcher tick updates - // `workTree` from the same statuses walk — so this effect deliberately - // does NOT depend on `status`: the old status dep re-walked the working - // tree over IPC a second time on every stage toggle. + // Files tab: lazily fetch the selected commit's immutable tree, or the + // working-tree listing when no commit detail is open. Working-tree freshness + // comes from `refreshSnapshot`, so this deliberately does NOT depend on + // `status`: the old status dep re-walked the tree after every stage toggle. const [treeLoading, setTreeLoading] = useState(false); + const [revisionTree, setRevisionTree] = useState(null); + const [treeError, setTreeError] = useState(null); useEffect(() => { if (tab !== 'files' || !meta?.path) return; let cancelled = false; setTreeLoading(true); - void refreshTree().finally(() => { - if (!cancelled) setTreeLoading(false); - }); + setTreeError(null); + if (selectedCommit) setRevisionTree(null); + const load = selectedCommit + ? tauri.repoTreeAt(meta.path, selectedCommit).then((tree) => { + if (!cancelled) setRevisionTree(tree); + }) + : refreshTree().then(() => { + if (!cancelled) setRevisionTree(null); + }); + void load + .catch((e) => { + if (!cancelled) { + setRevisionTree(null); + setTreeError(errMessage(e)); + } + }) + .finally(() => { + if (!cancelled) setTreeLoading(false); + }); return () => { cancelled = true; }; - }, [tab, meta?.path, refreshTree]); + }, [tab, meta?.path, selectedCommit, refreshTree]); - // Files tab — the Pierre tree is fed the whole working-tree listing. + // Files tab — Pierre receives either the selected revision or working tree. // Filtering is Pierre's own in-tree search box, so the shared filter box // (git tab only) no longer touches this list. - const filePaths = useMemo(() => workTree.map((e) => e.path), [workTree]); + const displayedTree = selectedCommit ? (revisionTree ?? []) : workTree; + const filePaths = useMemo(() => displayedTree.map((e) => e.path), [displayedTree]); const fileGitStatus = useMemo( () => - workTree.flatMap((e) => { + displayedTree.flatMap((e) => { const s = workStatusToGit(e.status); return s ? [{ path: e.path, status: s }] : []; }), - [workTree], + [displayedTree], ); // Rename / move — the drop handler for drag-to-move in the tree, and the // dialog behind the context menu's keyboard-operable equivalent. @@ -389,9 +407,9 @@ export function Sidebar({ onOpenRepo, onOpenRecent, onCreateStash, onCreateTag, const fileMenu = useCallback( (targets: string[]): TreeMenuItem[] => { const items: TreeMenuItem[] = [ - { label: 'Open', icon: 'content', onSelect: () => selectFile(targets[0]) }, + { label: 'Open', icon: 'content', onSelect: () => selectFile(targets[0], selectedCommit) }, ]; - if (targets.length === 1) { + if (!selectedCommit && targets.length === 1) { items.push({ label: 'Rename / move…', icon: 'file', @@ -400,6 +418,7 @@ export function Sidebar({ onOpenRepo, onOpenRecent, onCreateStash, onCreateTag, } // .gitignore quick actions for a single untracked file. if ( + !selectedCommit && targets.length === 1 && workTree.some((e) => e.path === targets[0] && e.status === 'UNTRACKED') ) { @@ -424,7 +443,7 @@ export function Sidebar({ onOpenRepo, onOpenRecent, onCreateStash, onCreateTag, }); return items; }, - [selectFile, workTree, gitignoreAdd, openIgnoreDialog, onToast], + [selectFile, selectedCommit, workTree, gitignoreAdd, openIgnoreDialog, onToast], ); const renameDialog = renameTarget ? ( @@ -1037,16 +1056,25 @@ export function Sidebar({ onOpenRepo, onOpenRecent, onCreateStash, onCreateTag, ) : (
{renameDialog} + {selectedCommit && ( +
+ Files at {selectedCommit.slice(0, 7)} +
+ )} selectFile(p)} + onSelect={(p) => selectFile(p, selectedCommit)} menuItems={fileMenu} search initialExpansion="closed" - emptyLabel={treeLoading ? 'Loading working tree…' : 'No files in the working tree.'} + emptyLabel={ + treeLoading + ? selectedCommit ? 'Loading commit tree…' : 'Loading working tree…' + : treeError ?? (selectedCommit ? 'No files at this commit.' : 'No files in the working tree.') + } />
)} diff --git a/ui/src/lib/tauri.ts b/ui/src/lib/tauri.ts index fc898ab..0798281 100644 --- a/ui/src/lib/tauri.ts +++ b/ui/src/lib/tauri.ts @@ -256,6 +256,8 @@ export const tauri = { repoCheckoutCommit: (path: string, rev: string) => invoke('repo_checkout_commit', { path, rev }), repoTree: (path: string) => invoke('repo_tree', { path }), + repoTreeAt: (path: string, rev: string) => + invoke('repo_tree_at', { path, rev }), repoSubmodules: (path: string) => invoke('repo_submodules', { path }), repoSubmoduleUpdate: ( path: string, diff --git a/ui/src/stores/repo.ts b/ui/src/stores/repo.ts index 19d9a4c..b16c192 100644 --- a/ui/src/stores/repo.ts +++ b/ui/src/stores/repo.ts @@ -223,6 +223,9 @@ export interface RepoState { * navigation (selecting a file, opening a repo, switching tabs). */ fileReturn: string | null; selectedFile: string | null; + /** Revision the selected file was opened from in the Files tree; `null` + * means the mutable working-tree copy. */ + selectedFileRevision: string | null; selectedRef: string | null; /** Re-open the tabs the user had open last time (called once at app start). */ @@ -622,7 +625,7 @@ export interface RepoState { forgetRecent(path: string): Promise; setView(view: View): void; - selectFile(path: string | null): void; + selectFile(path: string | null, revision?: string | null): void; selectRef(ref: string | null): void; /** Set the active file-view tab. */ setFileTab(tab: FileTab): void; @@ -747,6 +750,7 @@ const EMPTY_ACTIVE = { lastBulkDiscard: null as { oid: string; count: number; path: string } | null, lastDiscard: null as { patch: string; label: string; path: string } | null, selectedFile: null as string | null, + selectedFileRevision: null as string | null, fileReturn: null as string | null, selectedCommit: null as string | null, selectedCommitDiffs: [] as FileDiff[], @@ -2004,9 +2008,10 @@ export const useRepo = create((set, get) => ({ // Opening a file resets the file-view tab — Preview for renderable files // when the `fileOpenTab` setting says so, Content otherwise — and drops any // stale back-target; closing (null) just drops the back-target. - selectFile: (selectedFile) => + selectFile: (selectedFile, revision = null) => set({ selectedFile, + selectedFileRevision: selectedFile ? revision : null, view: selectedFile ? 'file' : get().view, fileReturn: null, ...(selectedFile diff --git a/ui/src/styles/features.css b/ui/src/styles/features.css index e1b2f15..b7bee6f 100644 --- a/ui/src/styles/features.css +++ b/ui/src/styles/features.css @@ -5952,6 +5952,17 @@ textarea.clone-input { display: flex; flex-direction: column; } +.side-files-revision { + flex: 0 0 auto; + padding: 6px 10px; + border-bottom: 0.5px solid var(--border); + color: var(--text-dim); + font-size: 11px; +} +.side-files-revision code { + color: var(--text); + font-family: var(--font-mono); +} /* ── Worktrees overview ─────────────────────────────────────────────────── */ .wt-view { diff --git a/ui/src/views/FileView.tsx b/ui/src/views/FileView.tsx index 3f1b63d..98f1bf4 100644 --- a/ui/src/views/FileView.tsx +++ b/ui/src/views/FileView.tsx @@ -33,7 +33,8 @@ const TABS: { id: Tab; label: string; icon: IconName }[] = [ /** * Tabbed file view (PRD §6.5), wired to `strand-core`: - * - **Content** — the working-tree file, syntax-highlighted via Pierre's ``. + * - **Content** — the working-tree file or selected revision, + * syntax-highlighted via Pierre's ``. * - **Preview** — rendered form of a renderable text file (SVG as an image, * markdown as a document); the tab only shows for those files. * - **History** — `git log --follow` for the path; selecting a commit shows @@ -45,6 +46,7 @@ const TABS: { id: Tab; label: string; icon: IconName }[] = [ */ export function FileView({ path }: { path: string }) { const activePath = useRepo((s) => s.activePath); + const revision = useRepo((s) => s.selectedFileRevision); const meta = useRepo((s) => s.meta); const repoName = repoFamilyName(meta); const setView = useRepo((s) => s.setView); @@ -102,6 +104,7 @@ export function FileView({ path }: { path: string }) { {path} + {revision && {revision.slice(0, 7)}}