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
This commit is contained in:
@@ -11,20 +11,24 @@
|
||||
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, GitRepositoryFile } from "../types";
|
||||
import type { ExplorerNode, ExplorerNodeKind, FileStatusKind, GitIgnoreKind, GitRepositoryFile } from "../types";
|
||||
import LanguageIcon from "./LanguageIcon.svelte";
|
||||
|
||||
interface Props {
|
||||
@@ -46,6 +50,8 @@
|
||||
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;
|
||||
}
|
||||
@@ -69,6 +75,8 @@
|
||||
onExternalDiff = () => {},
|
||||
onFileHistory = () => {},
|
||||
onBlame = () => {},
|
||||
onIgnore = () => {},
|
||||
onStopTracking = () => {},
|
||||
collapsed = false,
|
||||
onToggleCollapsed = () => {},
|
||||
}: Props = $props();
|
||||
@@ -76,6 +84,7 @@
|
||||
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 {
|
||||
@@ -183,14 +192,19 @@
|
||||
return "text";
|
||||
}
|
||||
|
||||
function openFileContextMenu(event: MouseEvent, node: ExplorerNode) {
|
||||
if (node.kind !== "file") return;
|
||||
function openNodeContextMenu(event: MouseEvent, node: ExplorerNode) {
|
||||
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));
|
||||
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() {
|
||||
@@ -232,6 +246,31 @@
|
||||
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();
|
||||
}
|
||||
@@ -239,6 +278,10 @@
|
||||
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
|
||||
@@ -338,7 +381,7 @@
|
||||
class:folder={node.kind === "folder"}
|
||||
style={`--depth: ${node.depth}`}
|
||||
title={node.path}
|
||||
oncontextmenu={(event) => openFileContextMenu(event, node)}
|
||||
oncontextmenu={(event) => openNodeContextMenu(event, node)}
|
||||
>
|
||||
{#if node.kind === "folder"}
|
||||
<button
|
||||
@@ -420,49 +463,60 @@
|
||||
|
||||
{#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}`}
|
||||
>
|
||||
<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>
|
||||
{#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}
|
||||
|
||||
Reference in New Issue
Block a user