feat(status-panel): add tree view for changes

The body:
Introduces a tree view for the status panel in addition to the
existing list view. A new toggle allows switching between modes.

It builds a hierarchical tree of folders and files from changed
paths and renders both unstaged and staged sections with
collapsible folders and indentation.

Adds responsive styling so the view switch condenses to icons on
narrow screens and includes styles for folders, chevrons, and
folder counts.

- Build and sort a tree structure from file paths.
- Render folders and files with indentation in tree view.
- Add responsive styling and a view switch for status changes.
This commit is contained in:
Christoph Brandau
2026-08-17 21:42:32 +02:00
parent fac22ff173
commit 8b559fa15c
2 changed files with 321 additions and 27 deletions
+149
View File
@@ -6191,6 +6191,80 @@ input:focus, textarea:focus, select:focus { box-shadow: 0 0 0 3px color-mix(in s
background: var(--color-surface-dim); background: var(--color-surface-dim);
} }
.status-toolbar .danger { margin-left: auto; } .status-toolbar .danger { margin-left: auto; }
.status-panel > .section-head {
display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
align-items: center;
}
.status-panel > .section-head > :first-child { justify-self: start; }
.status-panel > .section-head > .status-head-actions {
justify-self: end;
max-width: 100%;
}
.status-view-switch {
display: inline-grid;
grid-template-columns: repeat(2, auto);
align-items: center;
min-height: 26px;
padding: 2px;
border: 1px solid var(--color-border);
border-radius: 5px;
background: var(--color-surface-dim);
}
.status-view-switch button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 5px;
min-width: 0;
min-height: 20px;
padding: 0 6px;
border: 0;
border-radius: 3px;
color: var(--color-ink-faint);
background: transparent;
font-size: 9.5px;
font-weight: 700;
}
.status-view-switch button:hover:not(:disabled) {
color: var(--color-ink);
background: var(--color-surface-hover);
}
.status-view-switch button.active {
color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 13%, var(--color-surface-solid));
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-primary) 20%, transparent);
}
.status-view-icon {
position: relative;
display: inline-block;
flex: 0 0 auto;
width: 12px;
height: 10px;
}
.status-view-icon.list-icon {
background: repeating-linear-gradient(to bottom, currentColor 0 1px, transparent 1px 4px);
}
.status-view-icon.tree-icon::before {
content: "";
position: absolute;
top: 1px;
left: 1px;
width: 3px;
height: 3px;
background: currentColor;
box-shadow: 8px 0 0 currentColor, 8px 7px 0 currentColor;
}
.status-view-icon.tree-icon::after {
content: "";
position: absolute;
top: 2px;
bottom: 1px;
left: 2px;
width: 8px;
border-bottom: 1px solid currentColor;
border-left: 1px solid currentColor;
}
.status-panel { container-type: inline-size; } .status-panel { container-type: inline-size; }
.status-lanes { .status-lanes {
display: grid; display: grid;
@@ -6295,6 +6369,7 @@ input:focus, textarea:focus, select:focus { box-shadow: 0 0 0 3px color-mix(in s
.status-file-row:hover { background: var(--color-surface-hover); } .status-file-row:hover { background: var(--color-surface-hover); }
.status-file-row.selected { background: color-mix(in srgb, var(--color-primary) 9%, var(--color-surface-solid)); } .status-file-row.selected { background: color-mix(in srgb, var(--color-primary) 9%, var(--color-surface-solid)); }
.status-file-row.active { border-left-color: var(--color-primary); } .status-file-row.active { border-left-color: var(--color-primary); }
.status-file-row.status-tree-file { padding-left: calc(12px + var(--tree-indent, 0px)); }
.status-file-main { display: grid; justify-content: start; gap: 1px; min-width: 0; padding: 3px 8px; border: 0; background: transparent; text-align: left; } .status-file-main { display: grid; justify-content: start; gap: 1px; min-width: 0; padding: 3px 8px; border: 0; background: transparent; text-align: left; }
.status-file-main:hover:not(:disabled) { background: transparent; } .status-file-main:hover:not(:disabled) { background: transparent; }
.status-file-main strong, .status-file-main span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .status-file-main strong, .status-file-main span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
@@ -6304,6 +6379,75 @@ input:focus, textarea:focus, select:focus { box-shadow: 0 0 0 3px color-mix(in s
.status-file-row:hover .status-file-actions, .status-file-row:focus-within .status-file-actions { opacity: 1; } .status-file-row:hover .status-file-actions, .status-file-row:focus-within .status-file-actions { opacity: 1; }
.status-file-actions button { width: 28px; min-width: 28px; min-height: 28px; padding: 0; border-color: transparent; background: transparent; } .status-file-actions button { width: 28px; min-width: 28px; min-height: 28px; padding: 0; border-color: transparent; background: transparent; }
.status-file-actions .danger:hover:not(:disabled) { color: #e86060; background: rgba(232,96,96,.1); } .status-file-actions .danger:hover:not(:disabled) { color: #e86060; background: rgba(232,96,96,.1); }
.status-folder-row {
display: grid;
grid-template-columns: 14px 15px minmax(0, 1fr) auto;
align-items: center;
gap: 5px;
width: 100%;
min-height: 32px;
padding: 2px 12px 2px calc(12px + var(--tree-indent, 0px));
border: 0;
border-bottom: 1px solid var(--color-border-subtle);
border-radius: 0;
color: var(--color-ink-muted);
background: color-mix(in srgb, var(--color-surface-dim) 64%, transparent);
text-align: left;
}
.status-folder-row:hover:not(:disabled) {
color: var(--color-ink);
background: var(--color-surface-hover);
}
.status-tree-chevron {
width: 6px;
height: 6px;
border-right: 1.5px solid currentColor;
border-bottom: 1.5px solid currentColor;
transform: rotate(-45deg);
transition: transform 120ms ease;
}
.status-tree-chevron.expanded { transform: rotate(45deg) translate(-1px, -1px); }
.status-folder-icon {
position: relative;
width: 14px;
height: 10px;
margin-top: 2px;
border: 1px solid currentColor;
border-radius: 2px;
color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 7%, transparent);
}
.status-folder-icon::before {
content: "";
position: absolute;
top: -4px;
left: 1px;
width: 7px;
height: 4px;
border: 1px solid currentColor;
border-bottom: 0;
border-radius: 2px 2px 0 0;
}
.status-folder-icon.open { background: color-mix(in srgb, var(--color-primary) 14%, transparent); }
.status-folder-row strong {
overflow: hidden;
color: inherit;
font: 650 11px/1.2 var(--font-mono);
text-overflow: ellipsis;
white-space: nowrap;
}
.status-folder-count {
display: grid;
place-items: center;
min-width: 18px;
height: 18px;
padding-inline: 4px;
border-radius: 4px;
color: var(--color-ink-faint);
background: color-mix(in srgb, var(--color-ink-faint) 9%, transparent);
font-size: 9px;
font-weight: 750;
}
.status-lane-empty { .status-lane-empty {
display: grid; display: grid;
place-items: center; place-items: center;
@@ -7558,6 +7702,11 @@ input:focus, textarea:focus, select:focus { box-shadow: 0 0 0 3px color-mix(in s
.status-flow-divider span { transform: rotate(90deg); } .status-flow-divider span { transform: rotate(90deg); }
} }
@container (max-width: 520px) {
.status-view-switch button > span { display: none; }
.status-view-switch button { width: 24px; padding-inline: 0; }
}
@media (max-width: 760px) { @media (max-width: 760px) {
.status-lane-head { align-items: flex-start; flex-direction: column; } .status-lane-head { align-items: flex-start; flex-direction: column; }
.status-lane-actions { width: 100%; justify-content: flex-start; } .status-lane-actions { width: 100%; justify-content: flex-start; }
+172 -27
View File
@@ -1,5 +1,10 @@
<script lang="ts"> <script lang="ts">
import { ArrowLeft, ArrowRight, FileDiff, RotateCcw } from "@lucide/svelte"; import {
ArrowLeft,
ArrowRight,
FileDiff,
RotateCcw,
} from "@lucide/svelte";
import iconUrl from "../../../src-tauri/icons/icon.png"; import iconUrl from "../../../src-tauri/icons/icon.png";
import type { FileStatusKind, GitFileStatus, GitStatus } from "../types"; import type { FileStatusKind, GitFileStatus, GitStatus } from "../types";
@@ -22,6 +27,28 @@
onUnstageAll: () => void; onUnstageAll: () => void;
} }
type StatusViewMode = "list" | "tree";
type StatusLaneKind = "unstaged" | "staged";
interface StatusFolderNode {
kind: "folder";
name: string;
path: string;
depth: number;
fileCount: number;
children: StatusTreeNode[];
}
interface StatusFileNode {
kind: "file";
name: string;
path: string;
depth: number;
file: GitFileStatus;
}
type StatusTreeNode = StatusFolderNode | StatusFileNode;
let { let {
changedFiles = [], changedFiles = [],
stagedCount = 0, stagedCount = 0,
@@ -65,8 +92,94 @@
return `${file.old_path ?? ""}:${file.path}`; return `${file.old_path ?? ""}:${file.path}`;
} }
function sortStatusTree(nodes: StatusTreeNode[]) {
nodes.sort((a, b) => {
if (a.kind !== b.kind) return a.kind === "folder" ? -1 : 1;
return a.name.localeCompare(b.name);
});
for (const node of nodes) {
if (node.kind === "folder") sortStatusTree(node.children);
}
}
function buildStatusTree(files: GitFileStatus[]): StatusTreeNode[] {
const roots: StatusTreeNode[] = [];
const folders = new Map<string, StatusFolderNode>();
for (const file of [...files].sort((a, b) => a.path.localeCompare(b.path))) {
const parts = file.path.split(/[\\/]+/).filter(Boolean);
if (parts.length === 0) continue;
let currentPath = "";
let siblings = roots;
const ancestors: StatusFolderNode[] = [];
for (let index = 0; index < parts.length - 1; index++) {
currentPath = currentPath ? `${currentPath}/${parts[index]}` : parts[index];
let folder = folders.get(currentPath);
if (!folder) {
folder = {
kind: "folder",
name: parts[index],
path: currentPath,
depth: index,
fileCount: 0,
children: [],
};
folders.set(currentPath, folder);
siblings.push(folder);
}
ancestors.push(folder);
siblings = folder.children;
}
siblings.push({
kind: "file",
name: parts[parts.length - 1] ?? file.path,
path: file.path,
depth: Math.max(parts.length - 1, 0),
file,
});
for (const folder of ancestors) folder.fileCount += 1;
}
sortStatusTree(roots);
return roots;
}
function flattenStatusTree(nodes: StatusTreeNode[], lane: StatusLaneKind): StatusTreeNode[] {
const visible: StatusTreeNode[] = [];
for (const node of nodes) {
visible.push(node);
if (node.kind === "folder" && !collapsedStatusFolders.has(`${lane}:${node.path}`)) {
visible.push(...flattenStatusTree(node.children, lane));
}
}
return visible;
}
function listStatusRows(files: GitFileStatus[]): StatusFileNode[] {
return files.map((file) => ({
kind: "file",
name: fileName(file),
path: file.path,
depth: 0,
file,
}));
}
let selectedStatusPaths = $state<Set<string>>(new Set()); let selectedStatusPaths = $state<Set<string>>(new Set());
let selectionAnchorKey = $state(""); let selectionAnchorKey = $state("");
let statusView = $state<StatusViewMode>("list");
let collapsedStatusFolders = $state<Set<string>>(new Set());
function toggleStatusFolder(lane: StatusLaneKind, path: string) {
const next = new Set(collapsedStatusFolders);
const key = `${lane}:${path}`;
if (next.has(key)) next.delete(key);
else next.add(key);
collapsedStatusFolders = next;
}
function isStatusSelected(file: GitFileStatus): boolean { function isStatusSelected(file: GitFileStatus): boolean {
return selectedStatusPaths.has(fileKey(file)); return selectedStatusPaths.has(fileKey(file));
@@ -146,6 +259,10 @@
let hasStaged = $derived(changedFiles.some((f) => f.staged !== null)); let hasStaged = $derived(changedFiles.some((f) => f.staged !== null));
let unstagedFiles = $derived(changedFiles.filter((file) => file.unstaged !== null)); let unstagedFiles = $derived(changedFiles.filter((file) => file.unstaged !== null));
let stagedFiles = $derived(changedFiles.filter((file) => file.staged !== null)); let stagedFiles = $derived(changedFiles.filter((file) => file.staged !== null));
let unstagedTree = $derived(buildStatusTree(unstagedFiles));
let stagedTree = $derived(buildStatusTree(stagedFiles));
let visibleUnstagedRows = $derived(statusView === "tree" ? flattenStatusTree(unstagedTree, "unstaged") : listStatusRows(unstagedFiles));
let visibleStagedRows = $derived(statusView === "tree" ? flattenStatusTree(stagedTree, "staged") : listStatusRows(stagedFiles));
let selectedUnstagedCount = $derived(changedFiles.filter((f) => selectedStatusPaths.has(fileKey(f)) && f.unstaged !== null).length); let selectedUnstagedCount = $derived(changedFiles.filter((f) => selectedStatusPaths.has(fileKey(f)) && f.unstaged !== null).length);
let selectedStagedCount = $derived(changedFiles.filter((f) => selectedStatusPaths.has(fileKey(f)) && f.staged !== null).length); let selectedStagedCount = $derived(changedFiles.filter((f) => selectedStatusPaths.has(fileKey(f)) && f.staged !== null).length);
@@ -163,6 +280,14 @@
<span class="eyebrow">Workspace</span> <span class="eyebrow">Workspace</span>
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Changes</h2> <h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Changes</h2>
</div> </div>
<div class="status-view-switch" role="group" aria-label="Changes view">
<button class:active={statusView === "list"} type="button" onclick={() => statusView = "list"} title="List view" aria-label="List view" aria-pressed={statusView === "list"}>
<i class="status-view-icon list-icon" aria-hidden="true"></i><span>List</span>
</button>
<button class:active={statusView === "tree"} type="button" onclick={() => statusView = "tree"} title="Tree view" aria-label="Tree view" aria-pressed={statusView === "tree"}>
<i class="status-view-icon tree-icon" aria-hidden="true"></i><span>Tree</span>
</button>
</div>
<div class="status-head-actions"> <div class="status-head-actions">
<span class="pill pill-count">{stagedCount} staged</span> <span class="pill pill-count">{stagedCount} staged</span>
<span class="pill pill-count">{unstagedCount} unstaged</span> <span class="pill pill-count">{unstagedCount} unstaged</span>
@@ -206,20 +331,30 @@
</div> </div>
</header> </header>
<div class="status-file-list"> <div class="status-file-list">
{#each unstagedFiles as file (`unstaged:${fileKey(file)}`)} {#each visibleUnstagedRows as row (`unstaged:${row.kind}:${row.kind === "file" ? fileKey(row.file) : row.path}`)}
{@const stageTargets = selectedStageTargets(file)} {#if row.kind === "folder"}
<article class="status-file-row" class:selected={isStatusSelected(file)} class:active={selectedFilePath === file.path}> <button class="status-folder-row" type="button" style={`--tree-indent: ${row.depth * 15}px`} onclick={() => toggleStatusFolder("unstaged", row.path)} title={row.path} aria-expanded={!collapsedStatusFolders.has(`unstaged:${row.path}`)}>
<button class="status-file-main" type="button" onclick={(event) => handleFileSelect(event, file)} title={`Select ${displayPath(file)} in Explorer`}> <i class:expanded={!collapsedStatusFolders.has(`unstaged:${row.path}`)} class="status-tree-chevron" aria-hidden="true"></i>
<strong>{fileName(file)}</strong> <i class:open={!collapsedStatusFolders.has(`unstaged:${row.path}`)} class="status-folder-icon" aria-hidden="true"></i>
<span>{displayPath(file)}</span> <strong>{row.name}</strong>
<span class="status-folder-count">{row.fileCount}</span>
</button> </button>
<span class={`status-badge ${file.unstaged ?? "none"}`}>{statusLabel(file.unstaged)}</span> {:else}
<div class="status-file-actions"> {@const file = row.file}
<button type="button" onclick={() => stageFromFile(file)} disabled={isBusy || stageTargets.length === 0} title="Stage file"><ArrowRight size={14} aria-hidden="true" /></button> {@const stageTargets = selectedStageTargets(file)}
<button type="button" onclick={() => onPatch(file, false)} disabled={isBusy || !canPatch(file.unstaged)} title="Show unstaged details"><FileDiff size={14} aria-hidden="true" /></button> <article class={`status-file-row${statusView === "tree" ? " status-tree-file" : ""}`} style={`--tree-indent: ${row.depth * 15}px`} class:selected={isStatusSelected(file)} class:active={selectedFilePath === file.path}>
<button class="danger" type="button" onclick={() => discardUnstagedFromFile(file)} disabled={isBusy || stageTargets.length === 0} title="Discard unstaged changes"><RotateCcw size={14} aria-hidden="true" /></button> <button class="status-file-main" type="button" onclick={(event) => handleFileSelect(event, file)} title={`Select ${displayPath(file)} in Explorer`}>
</div> <strong>{fileName(file)}</strong>
</article> <span>{displayPath(file)}</span>
</button>
<span class={`status-badge ${file.unstaged ?? "none"}`}>{statusLabel(file.unstaged)}</span>
<div class="status-file-actions">
<button type="button" onclick={() => stageFromFile(file)} disabled={isBusy || stageTargets.length === 0} title="Stage file"><ArrowRight size={14} aria-hidden="true" /></button>
<button type="button" onclick={() => onPatch(file, false)} disabled={isBusy || !canPatch(file.unstaged)} title="Show unstaged details"><FileDiff size={14} aria-hidden="true" /></button>
<button class="danger" type="button" onclick={() => discardUnstagedFromFile(file)} disabled={isBusy || stageTargets.length === 0} title="Discard unstaged changes"><RotateCcw size={14} aria-hidden="true" /></button>
</div>
</article>
{/if}
{/each} {/each}
{#if unstagedCount === 0}<p class="status-lane-empty">No unstaged changes.</p>{/if} {#if unstagedCount === 0}<p class="status-lane-empty">No unstaged changes.</p>{/if}
</div> </div>
@@ -253,20 +388,30 @@
</div> </div>
</header> </header>
<div class="status-file-list"> <div class="status-file-list">
{#each stagedFiles as file (`staged:${fileKey(file)}`)} {#each visibleStagedRows as row (`staged:${row.kind}:${row.kind === "file" ? fileKey(row.file) : row.path}`)}
{@const unstageTargets = selectedUnstageTargets(file)} {#if row.kind === "folder"}
<article class="status-file-row" class:selected={isStatusSelected(file)} class:active={selectedFilePath === file.path}> <button class="status-folder-row" type="button" style={`--tree-indent: ${row.depth * 15}px`} onclick={() => toggleStatusFolder("staged", row.path)} title={row.path} aria-expanded={!collapsedStatusFolders.has(`staged:${row.path}`)}>
<button class="status-file-main" type="button" onclick={(event) => handleFileSelect(event, file)} title={`Select ${displayPath(file)} in Explorer`}> <i class:expanded={!collapsedStatusFolders.has(`staged:${row.path}`)} class="status-tree-chevron" aria-hidden="true"></i>
<strong>{fileName(file)}</strong> <i class:open={!collapsedStatusFolders.has(`staged:${row.path}`)} class="status-folder-icon" aria-hidden="true"></i>
<span>{displayPath(file)}</span> <strong>{row.name}</strong>
<span class="status-folder-count">{row.fileCount}</span>
</button> </button>
<span class={`status-badge ${file.staged ?? "none"}`}>{statusLabel(file.staged)}</span> {:else}
<div class="status-file-actions"> {@const file = row.file}
<button type="button" onclick={() => unstageFromFile(file)} disabled={isBusy || unstageTargets.length === 0} title="Unstage file"><ArrowLeft size={14} aria-hidden="true" /></button> {@const unstageTargets = selectedUnstageTargets(file)}
<button type="button" onclick={() => onPatch(file, true)} disabled={isBusy || !canPatch(file.staged)} title="Show staged details"><FileDiff size={14} aria-hidden="true" /></button> <article class={`status-file-row${statusView === "tree" ? " status-tree-file" : ""}`} style={`--tree-indent: ${row.depth * 15}px`} class:selected={isStatusSelected(file)} class:active={selectedFilePath === file.path}>
<button class="danger" type="button" onclick={() => discardStagedFromFile(file)} disabled={isBusy || unstageTargets.length === 0} title="Discard staged changes"><RotateCcw size={14} aria-hidden="true" /></button> <button class="status-file-main" type="button" onclick={(event) => handleFileSelect(event, file)} title={`Select ${displayPath(file)} in Explorer`}>
</div> <strong>{fileName(file)}</strong>
</article> <span>{displayPath(file)}</span>
</button>
<span class={`status-badge ${file.staged ?? "none"}`}>{statusLabel(file.staged)}</span>
<div class="status-file-actions">
<button type="button" onclick={() => unstageFromFile(file)} disabled={isBusy || unstageTargets.length === 0} title="Unstage file"><ArrowLeft size={14} aria-hidden="true" /></button>
<button type="button" onclick={() => onPatch(file, true)} disabled={isBusy || !canPatch(file.staged)} title="Show staged details"><FileDiff size={14} aria-hidden="true" /></button>
<button class="danger" type="button" onclick={() => discardStagedFromFile(file)} disabled={isBusy || unstageTargets.length === 0} title="Discard staged changes"><RotateCcw size={14} aria-hidden="true" /></button>
</div>
</article>
{/if}
{/each} {/each}
{#if stagedCount === 0}<p class="status-lane-empty">Stage files to include them in the next commit.</p>{/if} {#if stagedCount === 0}<p class="status-lane-empty">Stage files to include them in the next commit.</p>{/if}
</div> </div>