Skip to content

Commit e361404

Browse files
authored
feat(desktop): add list/tree toggle to history commit file list (#61)
1 parent d52b31f commit e361404

5 files changed

Lines changed: 195 additions & 38 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1414
- **Full-screen views + floating navigation dock** — the permanent left sidebar (3 tabs) and right Git Tree strip are gone. Navigation now lives in a floating bottom-center `AppDock` (Dashboard · Changes · PRs · Git Tree), so each view renders full-bleed. Dashboard, Changes (files │ diff │ collapsible commit rail), History (commit list │ diff) and PRs (list │ detail) compose their own panes; the Git Tree becomes a first-class full-screen `graph` view (selecting a commit drills into History). `RepoSidebar` gained a `pane` prop so a single component renders any one slice; the commit composer rail is collapsible and its state persisted. New `sidebar.toggleCommitPanel` i18n key in all 5 locales.
1515
- **File-tree view for the changes sidebar** — the Changes view gains a list/tree layout toggle (in the controls row, to the right of the monorepo scope picker; full-width with text labels when no scope picker is present). The tree layout nests each git section's files under their folders, with collapsible folders (per-section collapse state, persisted in `localStorage`); selecting a file auto-expands its ancestor folders. The chosen layout is persisted. Pure tree-building logic lives in a new `useFileTree` composable with unit tests; `viewLayout` / `viewAsList` / `viewAsTree` i18n keys added in all 5 locales.
1616
- **Per-file and per-folder discard** — every file row now carries a discard button alongside stage/unstage, and tree folder rows carry folder-level stage/unstage + discard (operating on all files under the folder). Section-header, folder and file actions share a new segmented "action group" control (fused square buttons split by a hairline divider), and the stage/unstage/discard buttons are now always visible rather than hover-only.
17+
- **File-tree view for the history (commit) sidebar** — the list/tree toggle now also applies when browsing a previously committed (already pushed) commit: the commit's changed-files list can render as a nested, collapsible folder tree, with the status badge per file and click-to-scroll preserved. The layout choice is shared with the Changes view — switching to tree in one switches both — and folder collapse state is persisted per folder path. The pure tree-builder (`useFileTree`) was generalised over any `{ path: string }` entry so it serves both the working-tree `RepoFileEntry` list and the commit `GitDiff` list. The lone "H" header icon is indented to align with the chevron-prefixed change sections.
1718

1819
### Changed
1920

apps/desktop/src/components/RepoSidebar.vue

Lines changed: 146 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,28 @@ function setChangesLayout(layout: "list" | "tree") {
202202
if (layout === "tree" && props.selectedFile) expandFoldersForFile(props.selectedFile);
203203
}
204204
205+
// History reuses the shared `changesLayout` state so the list/tree choice
206+
// stays in sync between the changes view and the read-only commit file list.
207+
208+
/** Section namespace used for the history tree's collapsed-folder state. */
209+
const HISTORY_SECTION = "history";
210+
211+
/** Flattened tree rows for the commit file list, recomputed when diffs or collapse state change. */
212+
const historyTreeRows = computed<TreeRow<GitDiff>[]>(() => {
213+
const root = buildFileTree(props.commitDiffs ?? []);
214+
return flattenTree(
215+
root,
216+
(folderPath) => !!collapsedFolders.value[folderKey(HISTORY_SECTION, folderPath)],
217+
);
218+
});
219+
220+
/** Map a diff path → its index in `commitDiffs`, for the scrollToFile emit. */
221+
const historyIndexByPath = computed<Record<string, number>>(() => {
222+
const map: Record<string, number> = {};
223+
(props.commitDiffs ?? []).forEach((d, i) => { map[d.path] = i; });
224+
return map;
225+
});
226+
205227
// ─── Tree view: collapsible folders (namespaced per section) ───
206228
const COLLAPSED_FOLDERS_KEY = "gitwand-collapsed-folders";
207229
@@ -970,35 +992,129 @@ function formatActivityDate(dateStr: string): string {
970992
<div class="sections" v-if="showPane('history')">
971993
<div class="section">
972994
<div class="section-header">
973-
<span class="section-icon" style="color: var(--color-accent)">H</span>
995+
<span class="section-icon section-icon--history" style="color: var(--color-accent)">H</span>
974996
<span class="section-label">{{ t('header.files') }}</span>
975997
<span class="section-count" v-if="commitDiffs">{{ commitDiffs.length }}</span>
998+
<span class="section-spacer"></span>
999+
<!-- List / tree layout toggle -->
1000+
<div
1001+
v-if="commitDiffs && commitDiffs.length > 0"
1002+
class="layout-toggle"
1003+
role="group"
1004+
:aria-label="t('sidebar.viewLayout')"
1005+
@click.stop
1006+
>
1007+
<button
1008+
class="layout-toggle-btn"
1009+
:class="{ 'layout-toggle-btn--active': changesLayout === 'list' }"
1010+
@click="setChangesLayout('list')"
1011+
:title="t('sidebar.viewAsList')"
1012+
:aria-pressed="changesLayout === 'list'"
1013+
>
1014+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
1015+
<line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/>
1016+
<line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/>
1017+
</svg>
1018+
</button>
1019+
<button
1020+
class="layout-toggle-btn"
1021+
:class="{ 'layout-toggle-btn--active': changesLayout === 'tree' }"
1022+
@click="setChangesLayout('tree')"
1023+
:title="t('sidebar.viewAsTree')"
1024+
:aria-pressed="changesLayout === 'tree'"
1025+
>
1026+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
1027+
<path d="M3 5h6l2 2h10v12H3z"/>
1028+
</svg>
1029+
</button>
1030+
</div>
9761031
</div>
9771032
<ul class="file-items" role="listbox" v-if="commitDiffs">
978-
<li
979-
v-for="(diff, idx) in commitDiffs"
980-
:key="idx"
981-
class="file-item"
982-
:class="{ 'file-item--selected': idx === visibleFileIdx }"
983-
role="option"
984-
:aria-selected="idx === visibleFileIdx"
985-
tabindex="0"
986-
@click="emit('scrollToFile', idx)"
987-
@dblclick="onFileItemDblClick"
988-
@keydown.enter="emit('scrollToFile', idx)"
989-
@keydown.space.prevent="emit('scrollToFile', idx)"
990-
>
991-
<span
992-
class="file-status-badge mono"
993-
:style="{ color: getFileStatus(diff).color }"
1033+
<!-- Flat list layout -->
1034+
<template v-if="changesLayout === 'list'">
1035+
<li
1036+
v-for="(diff, idx) in commitDiffs"
1037+
:key="idx"
1038+
class="file-item"
1039+
:class="{ 'file-item--selected': idx === visibleFileIdx }"
1040+
role="option"
1041+
:aria-selected="idx === visibleFileIdx"
1042+
tabindex="0"
1043+
@click="emit('scrollToFile', idx)"
1044+
@dblclick="onFileItemDblClick"
1045+
@keydown.enter="emit('scrollToFile', idx)"
1046+
@keydown.space.prevent="emit('scrollToFile', idx)"
9941047
>
995-
{{ getFileStatus(diff).icon }}
996-
</span>
997-
<div class="file-info">
998-
<span class="file-name mono">{{ fileName(diff.path) }}</span>
999-
<span class="file-dir muted">{{ fileDir(diff.path) }}</span>
1000-
</div>
1001-
</li>
1048+
<span
1049+
class="file-status-badge mono"
1050+
:style="{ color: getFileStatus(diff).color }"
1051+
>
1052+
{{ getFileStatus(diff).icon }}
1053+
</span>
1054+
<div class="file-info">
1055+
<span class="file-name mono">{{ fileName(diff.path) }}</span>
1056+
<span class="file-dir muted">{{ fileDir(diff.path) }}</span>
1057+
</div>
1058+
</li>
1059+
</template>
1060+
1061+
<!-- Nested folder tree layout -->
1062+
<template v-else>
1063+
<template
1064+
v-for="row in historyTreeRows"
1065+
:key="`${row.kind}-${row.path}`"
1066+
>
1067+
<!-- Folder row -->
1068+
<li
1069+
v-if="row.kind === 'folder'"
1070+
class="file-item tree-folder"
1071+
:style="{ paddingLeft: `${row.depth * 14 + 5}px` }"
1072+
role="option"
1073+
tabindex="0"
1074+
@click="toggleFolder(HISTORY_SECTION, row.path)"
1075+
@keydown.enter="toggleFolder(HISTORY_SECTION, row.path)"
1076+
@keydown.space.prevent="toggleFolder(HISTORY_SECTION, row.path)"
1077+
>
1078+
<svg
1079+
class="tree-chevron"
1080+
:class="{ 'tree-chevron--collapsed': collapsedFolders[folderKey(HISTORY_SECTION, row.path)] }"
1081+
width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"
1082+
>
1083+
<polyline points="6 9 12 15 18 9"/>
1084+
</svg>
1085+
<svg class="tree-folder-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
1086+
<path d="M3 5h6l2 2h10v12H3z"/>
1087+
</svg>
1088+
<span class="file-name mono tree-folder-name">{{ row.name }}</span>
1089+
<span class="tree-folder-count">{{ row.count }}</span>
1090+
</li>
1091+
1092+
<!-- File row -->
1093+
<li
1094+
v-else
1095+
class="file-item"
1096+
:class="{ 'file-item--selected': historyIndexByPath[row.path] === visibleFileIdx }"
1097+
:style="{ paddingLeft: `${row.depth * 14 + 18}px` }"
1098+
role="option"
1099+
:aria-selected="historyIndexByPath[row.path] === visibleFileIdx"
1100+
tabindex="0"
1101+
@click="emit('scrollToFile', historyIndexByPath[row.path])"
1102+
@dblclick="onFileItemDblClick"
1103+
@keydown.enter="emit('scrollToFile', historyIndexByPath[row.path])"
1104+
@keydown.space.prevent="emit('scrollToFile', historyIndexByPath[row.path])"
1105+
>
1106+
<span
1107+
class="file-status-badge mono"
1108+
:style="{ color: getFileStatus(row.file!).color }"
1109+
>
1110+
{{ getFileStatus(row.file!).icon }}
1111+
</span>
1112+
<div class="file-info">
1113+
<span class="file-name mono">{{ row.name }}</span>
1114+
</div>
1115+
</li>
1116+
</template>
1117+
</template>
10021118
</ul>
10031119
<div v-else-if="selectedCommitHash" class="empty-section">
10041120
<span class="empty-text">{{ t('log.noDiffForCommit') }}</span>
@@ -1937,6 +2053,12 @@ function formatActivityDate(dateStr: string): string {
19372053
margin-left:-2px;
19382054
}
19392055
2056+
/* History header has no chevron — indent the lone "H" to match the
2057+
icon alignment of the chevron-prefixed change sections. */
2058+
.section-icon--history {
2059+
margin-left: var(--space-1);
2060+
}
2061+
19402062
.section-label {
19412063
margin-top: -1px;
19422064
}

apps/desktop/src/composables/__tests__/useFileTree.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,4 +54,29 @@ describe("buildFileTree / flattenTree", () => {
5454
"a/b",
5555
]);
5656
});
57+
58+
it("is generic over any PathLike entry (e.g. commit diffs)", () => {
59+
// Mirrors the history sidebar: entries carry a `path` plus extra fields
60+
// (here a diff `status`) that must survive on the file row via `row.file`.
61+
interface DiffLike {
62+
path: string;
63+
status: "added" | "modified" | "deleted";
64+
}
65+
const diff = (path: string, status: DiffLike["status"]): DiffLike => ({ path, status });
66+
67+
const root = buildFileTree<DiffLike>([
68+
diff("src/a.ts", "modified"),
69+
diff("src/b.ts", "added"),
70+
]);
71+
const rows = flattenTree(root, never);
72+
73+
expect(rows.map((r) => `${r.kind}:${r.name}`)).toEqual([
74+
"folder:src",
75+
"file:a.ts",
76+
"file:b.ts",
77+
]);
78+
// The underlying typed entry is preserved on file rows.
79+
const aRow = rows.find((r) => r.kind === "file" && r.name === "a.ts");
80+
expect(aRow?.file).toEqual({ path: "src/a.ts", status: "modified" });
81+
});
5782
});

apps/desktop/src/composables/useFileTree.ts

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -11,29 +11,34 @@
1111
*/
1212
import type { RepoFileEntry } from "./useGitRepo";
1313

14-
export interface TreeRow {
14+
/** Minimal shape the tree builder needs from an entry: just a path. */
15+
export interface PathLike {
16+
path: string;
17+
}
18+
19+
export interface TreeRow<T extends PathLike = RepoFileEntry> {
1520
kind: "folder" | "file";
1621
/** Folder rows: cumulative dir path (no trailing slash). File rows: the entry's full path. */
1722
path: string;
1823
/** Display segment (folder name, or file leaf — may keep a trailing "/" for untracked dirs). */
1924
name: string;
2025
/** Nesting depth (0 = section root). */
2126
depth: number;
22-
/** File rows only: the underlying git entry. */
23-
file?: RepoFileEntry;
27+
/** File rows only: the underlying entry. */
28+
file?: T;
2429
/** Folder rows only: rolled-up count of files under this folder. */
2530
count?: number;
2631
}
2732

28-
interface FolderNode {
33+
interface FolderNode<T extends PathLike> {
2934
name: string;
3035
path: string;
31-
folders: Map<string, FolderNode>;
32-
files: RepoFileEntry[];
36+
folders: Map<string, FolderNode<T>>;
37+
files: T[];
3338
count: number;
3439
}
3540

36-
function newNode(name: string, path: string): FolderNode {
41+
function newNode<T extends PathLike>(name: string, path: string): FolderNode<T> {
3742
return { name, path, folders: new Map(), files: [], count: 0 };
3843
}
3944

@@ -45,8 +50,8 @@ function leafName(path: string): string {
4550
}
4651

4752
/** Build a nested folder tree from a flat list of entries. */
48-
export function buildFileTree(files: RepoFileEntry[]): FolderNode {
49-
const root = newNode("", "");
53+
export function buildFileTree<T extends PathLike>(files: T[]): FolderNode<T> {
54+
const root = newNode<T>("", "");
5055
for (const f of files) {
5156
// The leaf (last non-empty segment) is the file; preceding segments are folders.
5257
// Trailing "/" entries (untracked dirs) collapse to a single leaf node.
@@ -69,7 +74,7 @@ export function buildFileTree(files: RepoFileEntry[]): FolderNode {
6974
return root;
7075
}
7176

72-
function computeCount(node: FolderNode): number {
77+
function computeCount<T extends PathLike>(node: FolderNode<T>): number {
7378
let c = node.files.length;
7479
for (const child of node.folders.values()) c += computeCount(child);
7580
node.count = c;
@@ -81,12 +86,12 @@ function computeCount(node: FolderNode): number {
8186
* before files at each level, both sorted by name. A folder for which
8287
* `isCollapsed(path)` returns true hides its descendants.
8388
*/
84-
export function flattenTree(
85-
root: FolderNode,
89+
export function flattenTree<T extends PathLike>(
90+
root: FolderNode<T>,
8691
isCollapsed: (folderPath: string) => boolean,
8792
depth = 0,
88-
out: TreeRow[] = [],
89-
): TreeRow[] {
93+
out: TreeRow<T>[] = [],
94+
): TreeRow<T>[] {
9095
const folders = [...root.folders.values()].sort((a, b) => a.name.localeCompare(b.name));
9196
for (const folder of folders) {
9297
out.push({ kind: "folder", path: folder.path, name: folder.name, depth, count: folder.count });

website/changelog.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@ The Changes sidebar can now show your working-tree changes as a **nested folder
1515

1616
Every file row now has a **discard** button sitting next to stage/unstage, and folder rows in the tree get folder-level stage, unstage and discard that act on everything inside. The buttons are grouped into a tidy segmented control — and they're always visible now, so you no longer have to hover to find them.
1717

18+
### The folder tree follows you into history
19+
20+
The same list/tree toggle now works when you're looking at an earlier, already-pushed commit. Open any commit and its changed files can be shown as a nested folder tree instead of a flat list — collapse the folders you don't care about, click a file to jump straight to its diff, just like in the Changes view. The choice is shared: flip to tree once and it stays tree everywhere, whether you're staging new work or reviewing past commits.
21+
1822
### Every external link works in the Linux AppImage
1923

2024
External links did nothing in the released Linux AppImage — clicking "Open GitHub" or "Open Azure" during sign-in, a pull-request or issue link in the Launchpad, the "open this repo on the web" button, or the changelog link simply went nowhere, even though all of them worked when running GitWand from source. There were two reasons. The first: the helper GitWand used to hand a URL to your browser ran without checking whether it actually succeeded, so when the AppImage runtime's polluted library path made that helper (or the desktop tool it calls) crash on the wrong libraries, the failure vanished silently. The second: a handful of links were plain web anchors, which the desktop webview quietly ignores, so they never reached the opener at all. GitWand now tries several system openers in turn, cleans up the environment before each one, and reports a real error if they all fail — and a single catch-all sends every external link in the app through your system browser, so none of them can silently do nothing again. Thanks to @t1gu1 for the report.

0 commit comments

Comments
 (0)