Files
GitLite/src/lib/components/ExplorerPanel.svelte
T
Christoph Brandau 15d1f2bfd6 feat(external-tools): add cross-platform external tool discovery
Adds a new external tools subsystem to detect and launch
diff and editor tools across Windows, macOS, and Linux.
It exposes data models for tools, commands, and results to the UI
and serializes them for consumption by the app.

- Implement cross-platform discovery of editors and diff tools
- Expose serialized results to the UI for user selection
- Centralize per-OS known tool lists and overrides
2026-08-13 14:08:02 +02:00

469 lines
18 KiB
Svelte

<script lang="ts">
import {
Braces,
ChevronDown,
ChevronRight,
CodeXml,
Database,
FileArchive,
FileAudio,
FileCode,
FileCog,
FileImage,
FileJson,
FileSearch,
GitCompare,
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;
language?: "en" | "de";
editorName?: string;
diffName?: string;
onToggleFolder: (node: ExplorerNode) => void;
onExpandAllFolders: () => void;
onCollapseAllFolders: () => void;
onSelectNode: (node: ExplorerNode) => void;
onOpenFile: (node: ExplorerNode) => void;
onOpenInEditor: (node: ExplorerNode) => void;
onExternalDiff: (node: ExplorerNode) => void;
onFileHistory: (node: ExplorerNode) => void;
onBlame: (node: ExplorerNode) => void;
collapsed?: boolean;
onToggleCollapsed?: () => void;
}
let {
repoFiles = [],
expandedExplorerPaths = new Set(),
selectedExplorerPath = "",
selectedExplorerKind = "file",
hasRepository = false,
isBusy = false,
language = "en",
editorName = "Editor",
diffName = "diff tool",
onToggleFolder = () => {},
onExpandAllFolders = () => {},
onCollapseAllFolders = () => {},
onSelectNode = () => {},
onOpenFile = () => {},
onOpenInEditor = () => {},
onExternalDiff = () => {},
onFileHistory = () => {},
onBlame = () => {},
collapsed = false,
onToggleCollapsed = () => {},
}: Props = $props();
let contextNode = $state<ExplorerNode | null>(null);
let contextMenuX = $state(0);
let contextMenuY = $state(0);
const isGerman = $derived(language === "de");
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 - 220));
}
function closeFileContextMenu() {
contextNode = null;
}
function openContextFile() {
const node = contextNode;
if (!node || node.kind !== "file") return;
closeFileContextMenu();
onOpenFile(node);
}
function openContextFileInEditor() {
const node = contextNode;
if (!node || node.kind !== "file") return;
closeFileContextMenu();
onOpenInEditor(node);
}
function openContextExternalDiff() {
const node = contextNode;
if (!node || node.kind !== "file") return;
closeFileContextMenu();
onExternalDiff(node);
}
function openContextBlame() {
const node = contextNode;
if (!node || node.kind !== "file") return;
closeFileContextMenu();
onBlame(node);
}
function openContextFileHistory() {
const node = contextNode;
if (!node || node.kind !== "file" || !node.tracked) return;
closeFileContextMenu();
onFileHistory(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"));
let selectedFileNode = $derived(
selectedExplorerKind === "file"
? visibleNodes.find((node) => node.kind === "file" && node.path === selectedExplorerPath) ?? null
: null,
);
</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 explorer-tool-action"
type="button"
onclick={() => selectedFileNode && onOpenInEditor(selectedFileNode)}
disabled={isBusy || !selectedFileNode || selectedFileNode.status === "deleted"}
title={isGerman
? `Ausgewählte Datei in ${editorName} öffnen`
: `Open selected file in ${editorName}`}
aria-label={isGerman
? `Ausgewählte Datei in ${editorName} öffnen`
: `Open selected file in ${editorName}`}
>
<FileCode size={14} aria-hidden="true" />
</button>
<button
class="explorer-bulk-button explorer-tool-action"
type="button"
onclick={() => selectedFileNode && onExternalDiff(selectedFileNode)}
disabled={isBusy || !selectedFileNode || !selectedFileNode.tracked || selectedFileNode.status === "deleted"}
title={isGerman
? `Ausgewählte Datei mit HEAD in ${diffName} vergleichen`
: `Compare selected file with HEAD in ${diffName}`}
aria-label={isGerman
? `Ausgewählte Datei mit HEAD in ${diffName} vergleichen`
: `Compare selected file with HEAD in ${diffName}`}
>
<GitCompare size={14} aria-hidden="true" />
</button>
<span class="explorer-action-divider" aria-hidden="true"></span>
<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}`)}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="explorer-row"
class:active={selectedExplorerPath === node.path && selectedExplorerKind === node.kind}
class:folder={node.kind === "folder"}
style={`--depth: ${node.depth}`}
title={node.path}
oncontextmenu={(event) => openFileContextMenu(event, node)}
>
{#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)}
disabled={isBusy}
title={`Select ${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={openContextFileInEditor} disabled={contextNode.status === "deleted"}>
<FileCode size={14} aria-hidden="true" />
{isGerman ? `In ${editorName} öffnen` : `Open in ${editorName}`}
</button>
<button type="button" role="menuitem" onclick={openContextExternalDiff} disabled={!contextNode.tracked || contextNode.status === "deleted"}>
<GitCompare size={14} aria-hidden="true" />
{isGerman ? `Mit HEAD in ${diffName} vergleichen` : `Compare with HEAD in ${diffName}`}
</button>
<button
type="button"
role="menuitem"
onclick={openContextFileHistory}
disabled={!contextNode.tracked}
title={contextNode.tracked ? "Show the commit history for this file" : "File history is only available for tracked files"}
>
<History size={14} aria-hidden="true" />
File history
</button>
<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"}
>
<FileSearch size={14} aria-hidden="true" />
Blame
</button>
</div>
{/if}