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);
}
.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-lanes {
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.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.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:hover:not(:disabled) { background: transparent; }
.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-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-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 {
display: grid;
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); }
}
@container (max-width: 520px) {
.status-view-switch button > span { display: none; }
.status-view-switch button { width: 24px; padding-inline: 0; }
}
@media (max-width: 760px) {
.status-lane-head { align-items: flex-start; flex-direction: column; }
.status-lane-actions { width: 100%; justify-content: flex-start; }
+150 -5
View File
@@ -1,5 +1,10 @@
<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 type { FileStatusKind, GitFileStatus, GitStatus } from "../types";
@@ -22,6 +27,28 @@
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 {
changedFiles = [],
stagedCount = 0,
@@ -65,8 +92,94 @@
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 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 {
return selectedStatusPaths.has(fileKey(file));
@@ -146,6 +259,10 @@
let hasStaged = $derived(changedFiles.some((f) => f.staged !== null));
let unstagedFiles = $derived(changedFiles.filter((file) => file.unstaged !== 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 selectedStagedCount = $derived(changedFiles.filter((f) => selectedStatusPaths.has(fileKey(f)) && f.staged !== null).length);
@@ -163,6 +280,14 @@
<span class="eyebrow">Workspace</span>
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Changes</h2>
</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">
<span class="pill pill-count">{stagedCount} staged</span>
<span class="pill pill-count">{unstagedCount} unstaged</span>
@@ -206,9 +331,18 @@
</div>
</header>
<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}`)}
{#if row.kind === "folder"}
<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}`)}>
<i class:expanded={!collapsedStatusFolders.has(`unstaged:${row.path}`)} class="status-tree-chevron" aria-hidden="true"></i>
<i class:open={!collapsedStatusFolders.has(`unstaged:${row.path}`)} class="status-folder-icon" aria-hidden="true"></i>
<strong>{row.name}</strong>
<span class="status-folder-count">{row.fileCount}</span>
</button>
{:else}
{@const file = row.file}
{@const stageTargets = selectedStageTargets(file)}
<article class="status-file-row" class:selected={isStatusSelected(file)} class:active={selectedFilePath === file.path}>
<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="status-file-main" type="button" onclick={(event) => handleFileSelect(event, file)} title={`Select ${displayPath(file)} in Explorer`}>
<strong>{fileName(file)}</strong>
<span>{displayPath(file)}</span>
@@ -220,6 +354,7 @@
<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}
{#if unstagedCount === 0}<p class="status-lane-empty">No unstaged changes.</p>{/if}
</div>
@@ -253,9 +388,18 @@
</div>
</header>
<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}`)}
{#if row.kind === "folder"}
<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}`)}>
<i class:expanded={!collapsedStatusFolders.has(`staged:${row.path}`)} class="status-tree-chevron" aria-hidden="true"></i>
<i class:open={!collapsedStatusFolders.has(`staged:${row.path}`)} class="status-folder-icon" aria-hidden="true"></i>
<strong>{row.name}</strong>
<span class="status-folder-count">{row.fileCount}</span>
</button>
{:else}
{@const file = row.file}
{@const unstageTargets = selectedUnstageTargets(file)}
<article class="status-file-row" class:selected={isStatusSelected(file)} class:active={selectedFilePath === file.path}>
<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="status-file-main" type="button" onclick={(event) => handleFileSelect(event, file)} title={`Select ${displayPath(file)} in Explorer`}>
<strong>{fileName(file)}</strong>
<span>{displayPath(file)}</span>
@@ -267,6 +411,7 @@
<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}
{#if stagedCount === 0}<p class="status-lane-empty">Stage files to include them in the next commit.</p>{/if}
</div>