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:
+149
@@ -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; }
|
||||
|
||||
@@ -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,20 +331,30 @@
|
||||
</div>
|
||||
</header>
|
||||
<div class="status-file-list">
|
||||
{#each unstagedFiles as file (`unstaged:${fileKey(file)}`)}
|
||||
{@const stageTargets = selectedStageTargets(file)}
|
||||
<article class="status-file-row" 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>
|
||||
{#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>
|
||||
<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>
|
||||
{:else}
|
||||
{@const file = row.file}
|
||||
{@const stageTargets = selectedStageTargets(file)}
|
||||
<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>
|
||||
</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}
|
||||
{#if unstagedCount === 0}<p class="status-lane-empty">No unstaged changes.</p>{/if}
|
||||
</div>
|
||||
@@ -253,20 +388,30 @@
|
||||
</div>
|
||||
</header>
|
||||
<div class="status-file-list">
|
||||
{#each stagedFiles as file (`staged:${fileKey(file)}`)}
|
||||
{@const unstageTargets = selectedUnstageTargets(file)}
|
||||
<article class="status-file-row" 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>
|
||||
{#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>
|
||||
<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>
|
||||
{:else}
|
||||
{@const file = row.file}
|
||||
{@const unstageTargets = selectedUnstageTargets(file)}
|
||||
<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>
|
||||
</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}
|
||||
{#if stagedCount === 0}<p class="status-lane-empty">Stage files to include them in the next commit.</p>{/if}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user