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
380 lines
14 KiB
Svelte
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}
|