fix blocking UI when select the file in Status also select in the file History
340 lines
13 KiB
Svelte
340 lines
13 KiB
Svelte
<script lang="ts">
|
|
import {
|
|
Braces,
|
|
ChevronDown,
|
|
ChevronRight,
|
|
CodeXml,
|
|
Database,
|
|
FileArchive,
|
|
FileAudio,
|
|
FileCode,
|
|
FileCog,
|
|
FileImage,
|
|
FileJson,
|
|
FileSpreadsheet,
|
|
FileText,
|
|
FileType,
|
|
FileVideo,
|
|
Folder,
|
|
FolderOpen,
|
|
ExternalLink,
|
|
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;
|
|
}
|
|
|
|
let {
|
|
repoFiles = [],
|
|
expandedExplorerPaths = new Set(),
|
|
selectedExplorerPath = "",
|
|
selectedExplorerKind = "file",
|
|
hasRepository = false,
|
|
isBusy = false,
|
|
onToggleFolder = () => {},
|
|
onExpandAllFolders = () => {},
|
|
onCollapseAllFolders = () => {},
|
|
onSelectNode = () => {},
|
|
onOpenFile = () => {},
|
|
}: 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 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" 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>
|
|
</div>
|
|
</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>
|
|
{@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>
|
|
</div>
|
|
{/if}
|