Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
7 changes: 5 additions & 2 deletions TASKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` —
Expand Down Expand Up @@ -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`
Expand Down Expand Up @@ -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,
Expand Down
122 changes: 117 additions & 5 deletions crates/strand-core/src/tree.rs
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -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<Vec<WorkTreeEntry>> {
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
Expand Down Expand Up @@ -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<_>>(),
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<_>>(),
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);
}
}
8 changes: 8 additions & 0 deletions crates/strand-tauri/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -696,6 +696,14 @@ pub async fn repo_tree(path: String) -> CmdResult<Vec<WorkTreeEntry>> {
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<Vec<WorkTreeEntry>> {
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<Vec<Submodule>> {
run_blocking("submodules", move || Ok(Repo::discover(&path)?.submodules()?)).await
Expand Down
1 change: 1 addition & 0 deletions crates/strand-tauri/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
68 changes: 48 additions & 20 deletions ui/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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<typeof workTree | null>(null);
const [treeError, setTreeError] = useState<string | null>(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<GitStatusEntry[]>(
() =>
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.
Expand Down Expand Up @@ -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',
Expand All @@ -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')
) {
Expand All @@ -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 ? (
Expand Down Expand Up @@ -1037,16 +1056,25 @@ export function Sidebar({ onOpenRepo, onOpenRecent, onCreateStash, onCreateTag,
) : (
<div className="side-files">
{renameDialog}
{selectedCommit && (
<div className="side-files-revision" title={`Files at commit ${selectedCommit}`}>
Files at <code>{selectedCommit.slice(0, 7)}</code>
</div>
)}
<PierreTree
paths={filePaths}
gitStatus={fileGitStatus}
onMove={moveTo}
onMove={selectedCommit ? undefined : moveTo}
selectedPath={selectedFile}
onSelect={(p) => 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.')
}
/>
</div>
)}
Expand Down
2 changes: 2 additions & 0 deletions ui/src/lib/tauri.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,8 @@ export const tauri = {
repoCheckoutCommit: (path: string, rev: string) =>
invoke<CheckoutOutcome>('repo_checkout_commit', { path, rev }),
repoTree: (path: string) => invoke<WorkTreeEntry[]>('repo_tree', { path }),
repoTreeAt: (path: string, rev: string) =>
invoke<WorkTreeEntry[]>('repo_tree_at', { path, rev }),
repoSubmodules: (path: string) => invoke<Submodule[]>('repo_submodules', { path }),
repoSubmoduleUpdate: (
path: string,
Expand Down
Loading
Loading