This commit is contained in:
2026-06-27 23:08:19 +02:00
parent 25700821d1
commit 5b44a65e51
22 changed files with 3388 additions and 3538 deletions
+170
View File
@@ -0,0 +1,170 @@
<script lang="ts">
import { ChevronDown, ChevronRight, FileText, Folder, FolderOpen } from "@lucide/svelte";
import type { ExplorerNode, ExplorerNodeKind, FileStatusKind, GitRepositoryFile } from "../types";
interface Props {
repoFiles: GitRepositoryFile[];
expandedExplorerPaths: Set<string>;
selectedExplorerPath: string;
selectedExplorerKind: ExplorerNodeKind;
hasRepository: boolean;
isBusy: boolean;
onToggleFolder: (node: ExplorerNode) => void;
onSelectNode: (node: ExplorerNode) => void;
}
let {
repoFiles = [],
expandedExplorerPaths = new Set(),
selectedExplorerPath = "",
selectedExplorerKind = "file",
hasRepository = false,
isBusy = false,
onToggleFolder = () => {},
onSelectNode = () => {},
}: Props = $props();
function mergeExplorerStatus(current: FileStatusKind | null, next: FileStatusKind | null): FileStatusKind | null {
if (!next) return current;
if (!current) return next;
const priority: FileStatusKind[] = ["conflicted", "modified", "renamed", "deleted", "added", "untracked", "unknown"];
return priority.indexOf(next) < priority.indexOf(current) ? next : current;
}
function sortExplorerNodes(nodes: ExplorerNode[]) {
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) sortExplorerNodes(node.children);
}
function buildExplorerTree(files: GitRepositoryFile[]): ExplorerNode[] {
const roots: ExplorerNode[] = [];
const folders = new Map<string, ExplorerNode>();
for (const file of [...files].sort((a, b) => a.path.localeCompare(b.path))) {
const parts = file.path.split(/[\\/]+/).filter(Boolean);
let currentPath = "";
let siblings = roots;
let ancestors: ExplorerNode[] = [];
for (let i = 0; i < parts.length - 1; i++) {
currentPath = currentPath ? `${currentPath}/${parts[i]}` : parts[i];
let folderNode = folders.get(currentPath);
if (!folderNode) {
folderNode = { name: parts[i], path: currentPath, kind: "folder", status: null, tracked: true, depth: i, children: [] };
folders.set(currentPath, folderNode);
siblings.push(folderNode);
}
ancestors = [...ancestors, folderNode];
siblings = folderNode.children;
}
const fileNode: ExplorerNode = {
name: parts[parts.length - 1] ?? file.path,
path: file.path,
kind: "file",
status: file.status,
tracked: file.tracked,
depth: Math.max(parts.length - 1, 0),
children: [],
};
siblings.push(fileNode);
for (const folderNode of ancestors) {
folderNode.status = mergeExplorerStatus(folderNode.status, file.status);
folderNode.tracked = folderNode.tracked && file.tracked;
}
}
sortExplorerNodes(roots);
return roots;
}
function flattenExplorerTree(nodes: ExplorerNode[], expanded: Set<string>): ExplorerNode[] {
const visible: ExplorerNode[] = [];
for (const node of nodes) {
visible.push(node);
if (node.kind === "folder" && expanded.has(node.path)) {
visible.push(...flattenExplorerTree(node.children, expanded));
}
}
return visible;
}
function statusLabel(kind: FileStatusKind | null): string {
return kind ?? "none";
}
let explorerTree = $derived(buildExplorerTree(repoFiles));
let visibleNodes = $derived(flattenExplorerTree(explorerTree, expandedExplorerPaths));
</script>
<section class="panel grid grid-rows-[auto_1fr] overflow-hidden" aria-label="File explorer">
<div class="section-head">
<div>
<span class="eyebrow">Explorer</span>
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Files</h2>
</div>
<span class="pill pill-count">{repoFiles.length}</span>
</div>
{#if !hasRepository}
<p class="blank-state">Open a repository to browse files.</p>
{:else if repoFiles.length === 0}
<p class="blank-state">No files returned.</p>
{:else}
<div class="explorer-list overflow-auto p-2">
{#each visibleNodes as node (`${node.kind}:${node.path}`)}
<div
class="explorer-row"
class:active={selectedExplorerPath === node.path && selectedExplorerKind === node.kind}
class:folder={node.kind === "folder"}
style={`--depth: ${node.depth}`}
title={node.path}
>
{#if node.kind === "folder"}
<button
class="tree-toggle"
type="button"
onclick={() => onToggleFolder(node)}
disabled={isBusy}
title={expandedExplorerPaths.has(node.path) ? "Collapse folder" : "Expand folder"}
>
{#if expandedExplorerPaths.has(node.path)}
<ChevronDown size={14} aria-hidden="true" />
{:else}
<ChevronRight size={14} aria-hidden="true" />
{/if}
</button>
{#if expandedExplorerPaths.has(node.path)}
<FolderOpen size={15} aria-hidden="true" />
{:else}
<Folder size={15} aria-hidden="true" />
{/if}
{:else}
<span class="tree-spacer"></span>
<FileText size={15} aria-hidden="true" />
{/if}
<button
class="explorer-select"
type="button"
onclick={() => onSelectNode(node)}
disabled={isBusy}
title={`Show history for ${node.path}`}
>
<span>{node.name}</span>
</button>
{#if node.status}
<small class={`status-badge ${node.status}`}>{statusLabel(node.status)}</small>
{:else if !node.tracked}
<small class="status-badge untracked">untracked</small>
{/if}
</div>
{/each}
</div>
{/if}
</section>