Files
GitLite/src/lib/components/ExplorerPanel.svelte
T
Christoph Brandau 8627012d93 feat(ui): enhance panel resizing and state persistence
This update introduces functionality for resizing various panels in the UI, including the left sidebar, branch panel, stash panel, and file history. It also adds state persistence for panel sizes and collapsed states using local storage, improving user experience by maintaining preferences across sessions.

- Implemented resizing functionality for multiple UI panels
- Added local storage support for panel dimensions and collapsed states
- Enhanced accessibility features for panel controls
2026-07-09 08:33:09 +02:00

380 lines
14 KiB
Svelte

<script lang="ts">
import {
Braces,
ChevronDown,
ChevronRight,
CodeXml,
Database,
FileArchive,
FileAudio,
FileCode,
FileCog,
FileImage,
FileJson,
FileSpreadsheet,
FileText,
FileType,
FileVideo,
Folder,
FolderOpen,
ExternalLink,
History,
Terminal,
} from "@lucide/svelte";
import { languageIconForPath } from "../languageIcons";
import type { ExplorerNode, ExplorerNodeKind, FileStatusKind, GitRepositoryFile } from "../types";
import LanguageIcon from "./LanguageIcon.svelte";
interface Props {
repoFiles: GitRepositoryFile[];
expandedExplorerPaths: Set<string>;
selectedExplorerPath: string;
selectedExplorerKind: ExplorerNodeKind;
hasRepository: boolean;
isBusy: boolean;
onToggleFolder: (node: ExplorerNode) => void;
onExpandAllFolders: () => void;
onCollapseAllFolders: () => void;
onSelectNode: (node: ExplorerNode) => void;
onOpenFile: (node: ExplorerNode) => void;
onBlame: (node: ExplorerNode) => void;
collapsed?: boolean;
onToggleCollapsed?: () => void;
}
let {
repoFiles = [],
expandedExplorerPaths = new Set(),
selectedExplorerPath = "",
selectedExplorerKind = "file",
hasRepository = false,
isBusy = false,
onToggleFolder = () => {},
onExpandAllFolders = () => {},
onCollapseAllFolders = () => {},
onSelectNode = () => {},
onOpenFile = () => {},
onBlame = () => {},
collapsed = false,
onToggleCollapsed = () => {},
}: Props = $props();
let contextNode = $state<ExplorerNode | null>(null);
let contextMenuX = $state(0);
let contextMenuY = $state(0);
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";
}
function extensionFor(path: string): string {
const name = path.split(/[\\/]/).pop()?.toLowerCase() ?? "";
const index = name.lastIndexOf(".");
return index > 0 ? name.slice(index + 1) : "";
}
function fileIconKind(node: ExplorerNode): string {
const name = node.name.toLowerCase();
const ext = extensionFor(node.path);
if (["package.json", "tsconfig.json", "jsconfig.json", "composer.json", "deno.json", "tauri.conf.json"].includes(name)) return "json";
if (["cargo.toml", "package-lock.json", "pnpm-lock.yaml", "yarn.lock", "bun.lockb"].includes(name)) return "config";
if (["dockerfile", "makefile", "justfile", "rakefile", "gemfile", ".env", ".gitignore", ".gitattributes", ".npmrc", ".editorconfig"].includes(name)) return "config";
if (["js", "jsx", "ts", "tsx", "svelte", "vue", "astro", "rs", "go", "java", "kt", "kts", "cs", "cpp", "cxx", "cc", "c", "h", "hpp", "swift", "php", "rb", "py", "lua", "dart", "scala", "zig", "ex", "exs", "erl", "hrl", "fs", "fsx", "fsi", "clj", "cljs"].includes(ext)) return "code";
if (["html", "htm", "xml", "xaml", "svg"].includes(ext)) return "markup";
if (["css", "scss", "sass", "less", "postcss"].includes(ext)) return "style";
if (["json", "jsonc", "json5"].includes(ext)) return "json";
if (["toml", "yaml", "yml", "ini", "conf", "config", "properties", "env"].includes(ext)) return "config";
if (["sh", "bash", "zsh", "fish", "ps1", "bat", "cmd"].includes(ext)) return "script";
if (["md", "mdx", "txt", "log", "rst", "adoc", "tex"].includes(ext)) return "text";
if (["png", "jpg", "jpeg", "gif", "webp", "bmp", "ico", "avif", "tif", "tiff"].includes(ext)) return "image";
if (["mp3", "wav", "ogg", "flac", "m4a", "aac"].includes(ext)) return "audio";
if (["mp4", "mov", "avi", "mkv", "webm", "wmv"].includes(ext)) return "video";
if (["zip", "rar", "7z", "tar", "gz", "tgz", "bz2", "xz"].includes(ext)) return "archive";
if (["csv", "tsv", "xls", "xlsx", "ods"].includes(ext)) return "sheet";
if (["sql", "sqlite", "sqlite3", "db"].includes(ext)) return "database";
if (["ttf", "otf", "woff", "woff2", "eot"].includes(ext)) return "font";
return "text";
}
function openFileContextMenu(event: MouseEvent, node: ExplorerNode) {
if (node.kind !== "file") return;
event.preventDefault();
event.stopPropagation();
contextNode = node;
contextMenuX = Math.max(8, Math.min(event.clientX + 2, window.innerWidth - 192));
contextMenuY = Math.max(8, Math.min(event.clientY + 2, window.innerHeight - 56));
}
function closeFileContextMenu() {
contextNode = null;
}
function openContextFile() {
const node = contextNode;
if (!node || node.kind !== "file") return;
closeFileContextMenu();
onOpenFile(node);
}
function openContextBlame() {
const node = contextNode;
if (!node || node.kind !== "file") return;
closeFileContextMenu();
onBlame(node);
}
function handleWindowKeydown(event: KeyboardEvent) {
if (event.key === "Escape") closeFileContextMenu();
}
let explorerTree = $derived(buildExplorerTree(repoFiles));
let visibleNodes = $derived(flattenExplorerTree(explorerTree, expandedExplorerPaths));
let hasFolders = $derived(explorerTree.some((node) => node.kind === "folder"));
</script>
<svelte:window on:click={closeFileContextMenu} on:keydown={handleWindowKeydown} on:contextmenu|capture={closeFileContextMenu} />
<section class="panel explorer-panel grid grid-rows-[auto_1fr] overflow-hidden" class:collapsed 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>
<div class="explorer-head-actions">
<button
class="explorer-bulk-button"
type="button"
onclick={onExpandAllFolders}
disabled={isBusy || !hasRepository || !hasFolders}
title="Alle Ordner aufklappen"
aria-label="Alle Ordner aufklappen"
>
<FolderOpen size={14} aria-hidden="true" />
</button>
<button
class="explorer-bulk-button"
type="button"
onclick={onCollapseAllFolders}
disabled={isBusy || !hasRepository || !hasFolders || expandedExplorerPaths.size === 0}
title="Alle Ordner zuklappen"
aria-label="Alle Ordner zuklappen"
>
<Folder size={14} aria-hidden="true" />
</button>
<span class="pill pill-count">{repoFiles.length}</span>
<button
class="explorer-bulk-button panel-collapse-toggle"
type="button"
onclick={onToggleCollapsed}
aria-expanded={!collapsed}
title={collapsed ? "Expand file explorer" : "Collapse file explorer"}
aria-label={collapsed ? "Expand file explorer panel" : "Collapse file explorer panel"}
>
{#if collapsed}
<ChevronRight size={14} aria-hidden="true" />
{:else}
<ChevronDown size={14} aria-hidden="true" />
{/if}
</button>
</div>
</div>
{#if collapsed}
<!-- collapsed -->
{:else 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>
{@const languageIcon = languageIconForPath(node.path)}
{@const iconKind = fileIconKind(node)}
{#if languageIcon}
<LanguageIcon icon={languageIcon.icon} title={languageIcon.title} />
{:else if iconKind === "code"}
<FileCode class={`file-icon ${iconKind}`} size={15} aria-hidden="true" />
{:else if iconKind === "markup"}
<CodeXml class={`file-icon ${iconKind}`} size={15} aria-hidden="true" />
{:else if iconKind === "style"}
<Braces class={`file-icon ${iconKind}`} size={15} aria-hidden="true" />
{:else if iconKind === "json"}
<FileJson class={`file-icon ${iconKind}`} size={15} aria-hidden="true" />
{:else if iconKind === "config"}
<FileCog class={`file-icon ${iconKind}`} size={15} aria-hidden="true" />
{:else if iconKind === "script"}
<Terminal class={`file-icon ${iconKind}`} size={15} aria-hidden="true" />
{:else if iconKind === "image"}
<FileImage class={`file-icon ${iconKind}`} size={15} aria-hidden="true" />
{:else if iconKind === "audio"}
<FileAudio class={`file-icon ${iconKind}`} size={15} aria-hidden="true" />
{:else if iconKind === "video"}
<FileVideo class={`file-icon ${iconKind}`} size={15} aria-hidden="true" />
{:else if iconKind === "archive"}
<FileArchive class={`file-icon ${iconKind}`} size={15} aria-hidden="true" />
{:else if iconKind === "sheet"}
<FileSpreadsheet class={`file-icon ${iconKind}`} size={15} aria-hidden="true" />
{:else if iconKind === "database"}
<Database class={`file-icon ${iconKind}`} size={15} aria-hidden="true" />
{:else if iconKind === "font"}
<FileType class={`file-icon ${iconKind}`} size={15} aria-hidden="true" />
{:else}
<FileText class={`file-icon ${iconKind}`} size={15} aria-hidden="true" />
{/if}
{/if}
<button
class="explorer-select"
type="button"
onclick={() => onSelectNode(node)}
oncontextmenu={(event) => openFileContextMenu(event, 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>
{#if contextNode}
<div
class="explorer-context-menu"
style={`left: ${contextMenuX}px; top: ${contextMenuY}px;`}
role="menu"
tabindex="-1"
aria-label={`Actions for ${contextNode.path}`}
>
<button
type="button"
role="menuitem"
onclick={openContextFile}
disabled={contextNode.status === "deleted"}
title={contextNode.status === "deleted" ? "Deleted files cannot be revealed in Explorer" : "Reveal this file in Explorer"}
>
<ExternalLink size={14} aria-hidden="true" />
Open in Explorer
</button>
<button
type="button"
role="menuitem"
onclick={openContextBlame}
disabled={!contextNode.tracked || contextNode.status === "deleted"}
title={!contextNode.tracked || contextNode.status === "deleted" ? "Blame is only available for tracked files" : "Show blame for this file"}
>
<History size={14} aria-hidden="true" />
Blame
</button>
</div>
{/if}