Files
GitLite/src/lib/components/ExplorerPanel.svelte
T
Christoph Brandau e4697c74b6 feat(git): add ignore and untrack paths commands
The changes introduce server-side commands to manage gitignore
 rules and to untrack paths without deleting local files.
 A new GitIgnoreKind enum and helper functions normalize targets
 and build proper ignore patterns, and UI code was wired to use
 these commands.

- add_to_gitignore command and related helpers
- untrack_paths command to remove paths from the index
- UI wiring to expose ignore and untrack actions in explorer
2026-08-18 13:09:33 +02:00

523 lines
21 KiB
Svelte

<script lang="ts">
import {
Braces,
ChevronDown,
ChevronRight,
CodeXml,
Database,
FileArchive,
FileAudio,
FileCode,
FileCog,
FileImage,
FileJson,
FileMinus2,
FileSearch,
GitCompare,
FileSpreadsheet,
FileText,
FileType,
FileVideo,
FileX,
Folder,
FolderMinus,
FolderOpen,
FolderX,
ExternalLink,
History,
Terminal,
} from "@lucide/svelte";
import { languageIconForPath } from "../languageIcons";
import type { ExplorerNode, ExplorerNodeKind, FileStatusKind, GitIgnoreKind, 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;
onIgnore: (target: string, kind: GitIgnoreKind) => void;
onStopTracking: (target: string, kind: "file" | "folder") => 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 = () => {},
onIgnore = () => {},
onStopTracking = () => {},
collapsed = false,
onToggleCollapsed = () => {},
}: Props = $props();
let contextNode = $state<ExplorerNode | null>(null);
let contextMenuX = $state(0);
let contextMenuY = $state(0);
let contextMenuElement = $state<HTMLDivElement | null>(null);
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 openNodeContextMenu(event: MouseEvent, node: ExplorerNode) {
event.preventDefault();
event.stopPropagation();
contextNode = node;
contextMenuX = Math.max(8, Math.min(event.clientX + 2, window.innerWidth - 248));
contextMenuY = Math.max(8, Math.min(event.clientY + 2, window.innerHeight - 320));
requestAnimationFrame(() => {
if (!contextMenuElement) return;
const bounds = contextMenuElement.getBoundingClientRect();
contextMenuX = Math.max(8, Math.min(event.clientX + 2, window.innerWidth - bounds.width - 8));
contextMenuY = Math.max(8, Math.min(event.clientY + 2, window.innerHeight - bounds.height - 8));
});
}
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 explorerFileNodes(node: ExplorerNode): ExplorerNode[] {
if (node.kind === "file") return [node];
return node.children.flatMap(explorerFileNodes);
}
function isIgnoreableExplorerFile(node: ExplorerNode): boolean {
return node.kind === "file" && !node.tracked && node.path.replace(/\\/g, "/").toLowerCase() !== ".gitignore";
}
function runContextIgnore(kind: GitIgnoreKind) {
const node = contextNode;
if (!node) return;
if (kind === "folder" && node.kind !== "folder") return;
if ((kind === "file" || kind === "extension") && node.kind !== "file") return;
closeFileContextMenu();
onIgnore(node.path, kind);
}
function runContextStopTracking() {
const node = contextNode;
if (!node) return;
closeFileContextMenu();
onStopTracking(node.path, node.kind);
}
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 contextFiles = $derived(contextNode ? explorerFileNodes(contextNode) : []);
let contextCanIgnore = $derived(contextFiles.some(isIgnoreableExplorerFile));
let contextCanStopTracking = $derived(contextFiles.some((node) => node.tracked));
let contextIgnoreExtension = $derived(contextNode?.kind === "file" && contextCanIgnore ? extensionFor(contextNode.path) : "");
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) => openNodeContextMenu(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
bind:this={contextMenuElement}
class="explorer-context-menu"
style={`left: ${contextMenuX}px; top: ${contextMenuY}px;`}
role="menu"
tabindex="-1"
aria-label={`Actions for ${contextNode.path}`}
>
{#if contextNode.kind === "file"}
<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" />
{isGerman ? "Dateiverlauf" : "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" />
{isGerman ? "Im Explorer öffnen" : "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>
{/if}
{#if contextCanStopTracking || contextCanIgnore}
<div class="menu-separator" role="separator"></div>
{/if}
{#if contextCanStopTracking}
<button class="untrack" type="button" role="menuitem" onclick={runContextStopTracking} disabled={isBusy} title="Keep the working-tree content and remove it from the Git index">
{#if contextNode.kind === "folder"}<FolderMinus size={14} aria-hidden="true" />{:else}<FileMinus2 size={14} aria-hidden="true" />{/if}
{isGerman ? `${contextNode.kind === "folder" ? "Ordner" : "Datei"} nicht mehr tracken` : `Stop tracking ${contextNode.kind}`}
</button>
{/if}
{#if contextCanIgnore && contextNode.kind === "file"}
<button class="ignore" type="button" role="menuitem" onclick={() => runContextIgnore("file")} disabled={isBusy} title={`Add /${contextNode.path.replace(/\\/g, "/")} to .gitignore`}>
<FileX size={14} aria-hidden="true" />
{isGerman ? "Datei ignorieren" : "Ignore file"}
</button>
{#if contextIgnoreExtension}
<button class="ignore" type="button" role="menuitem" onclick={() => runContextIgnore("extension")} disabled={isBusy} title={`Add *.${contextIgnoreExtension} to .gitignore`}>
<FileType size={14} aria-hidden="true" />
{isGerman ? `Alle *.${contextIgnoreExtension}-Dateien ignorieren` : `Ignore all *.${contextIgnoreExtension} files`}
</button>
{/if}
{:else if contextCanIgnore && contextNode.kind === "folder"}
<button class="ignore" type="button" role="menuitem" onclick={() => runContextIgnore("folder")} disabled={isBusy} title={`Add /${contextNode.path.replace(/\\/g, "/").replace(/\/+$/, "")}/ to .gitignore`}>
<FolderX size={14} aria-hidden="true" />
{isGerman ? "Ordner ignorieren" : "Ignore folder"}
</button>
{/if}
</div>
{/if}