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:
@@ -4,13 +4,18 @@
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
FileDiff,
|
||||
FileMinus2,
|
||||
FileType,
|
||||
FileX,
|
||||
Folder,
|
||||
FolderOpen,
|
||||
FolderMinus,
|
||||
FolderTree,
|
||||
FolderX,
|
||||
RotateCcw,
|
||||
} from "@lucide/svelte";
|
||||
import iconUrl from "../../../src-tauri/icons/icon.png";
|
||||
import type { FileStatusKind, GitFileStatus, GitStatus } from "../types";
|
||||
import type { FileStatusKind, GitFileStatus, GitIgnoreKind, GitStatus } from "../types";
|
||||
|
||||
interface Props {
|
||||
changedFiles: GitFileStatus[];
|
||||
@@ -27,6 +32,8 @@
|
||||
onDiscard: (files: GitFileStatus[], staged: boolean) => void;
|
||||
onDiscardMany: (files: GitFileStatus[]) => void;
|
||||
onStash: (files: GitFileStatus[], label: string) => void;
|
||||
onIgnore: (target: string, kind: GitIgnoreKind) => void;
|
||||
onStopTracking: (target: string, kind: "file" | "folder") => void;
|
||||
onPatch: (file: GitFileStatus, staged: boolean) => void;
|
||||
onStageAll: () => void;
|
||||
onUnstageAll: () => void;
|
||||
@@ -76,6 +83,8 @@
|
||||
onDiscard = () => {},
|
||||
onDiscardMany = () => {},
|
||||
onStash = () => {},
|
||||
onIgnore = () => {},
|
||||
onStopTracking = () => {},
|
||||
onPatch = () => {},
|
||||
onStageAll = () => {},
|
||||
onUnstageAll = () => {},
|
||||
@@ -188,6 +197,7 @@
|
||||
let statusContextTarget = $state<StatusContextTarget | null>(null);
|
||||
let statusContextMenuX = $state(0);
|
||||
let statusContextMenuY = $state(0);
|
||||
let statusContextMenuElement = $state<HTMLDivElement | null>(null);
|
||||
|
||||
function toggleStatusFolder(lane: StatusLaneKind, path: string) {
|
||||
const next = new Set(collapsedStatusFolders);
|
||||
@@ -220,6 +230,12 @@
|
||||
statusContextMenuX = Math.max(8, Math.min(event.clientX, window.innerWidth - 288));
|
||||
statusContextMenuY = Math.max(8, Math.min(event.clientY, window.innerHeight - 174));
|
||||
statusContextTarget = { lane, kind, label, files };
|
||||
requestAnimationFrame(() => {
|
||||
if (!statusContextMenuElement) return;
|
||||
const bounds = statusContextMenuElement.getBoundingClientRect();
|
||||
statusContextMenuX = Math.max(8, Math.min(event.clientX, window.innerWidth - bounds.width - 8));
|
||||
statusContextMenuY = Math.max(8, Math.min(event.clientY, window.innerHeight - bounds.height - 8));
|
||||
});
|
||||
}
|
||||
|
||||
function statusContextName(label: string): string {
|
||||
@@ -252,6 +268,45 @@
|
||||
onStash(target.files, target.label);
|
||||
}
|
||||
|
||||
function isIgnoreableNewFile(file: GitFileStatus): boolean {
|
||||
const path = file.path.replace(/\\/g, "/").toLowerCase();
|
||||
if (path === ".gitignore") return false;
|
||||
return file.unstaged === "untracked" || (file.staged === "added" && file.old_path === null);
|
||||
}
|
||||
|
||||
function isTrackedStatusFile(file: GitFileStatus): boolean {
|
||||
return file.unstaged !== "untracked" && file.staged !== "deleted";
|
||||
}
|
||||
|
||||
function statusContextExtension(label: string): string {
|
||||
const name = statusContextName(label);
|
||||
const separator = name.lastIndexOf(".");
|
||||
return separator > 0 && separator < name.length - 1 ? name.slice(separator + 1) : "";
|
||||
}
|
||||
|
||||
function statusContextFolder(target: StatusContextTarget): string {
|
||||
if (target.kind === "folder") return target.label.replace(/\\/g, "/").replace(/\/+$/, "");
|
||||
const normalized = target.label.replace(/\\/g, "/");
|
||||
const separator = normalized.lastIndexOf("/");
|
||||
return separator > 0 ? normalized.slice(0, separator) : "";
|
||||
}
|
||||
|
||||
function runStatusContextIgnoreAction(kind: GitIgnoreKind) {
|
||||
const target = statusContextTarget;
|
||||
if (!target) return;
|
||||
const ignoreTarget = kind === "folder" ? statusContextFolder(target) : target.label;
|
||||
if (!ignoreTarget) return;
|
||||
closeStatusContextMenu();
|
||||
onIgnore(ignoreTarget, kind);
|
||||
}
|
||||
|
||||
function runStatusContextStopTracking() {
|
||||
const target = statusContextTarget;
|
||||
if (!target) return;
|
||||
closeStatusContextMenu();
|
||||
onStopTracking(target.label, target.kind);
|
||||
}
|
||||
|
||||
function handleStatusWindowKeydown(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") closeStatusContextMenu();
|
||||
}
|
||||
@@ -340,6 +395,10 @@
|
||||
let visibleStagedRows = $derived(statusView === "tree" ? flattenStatusTree(stagedTree, "staged") : listStatusRows(stagedFiles));
|
||||
let selectedUnstagedCount = $derived(changedFiles.filter((f) => selectedStatusPaths.has(fileKey(f)) && f.unstaged !== null).length);
|
||||
let selectedStagedCount = $derived(changedFiles.filter((f) => selectedStatusPaths.has(fileKey(f)) && f.staged !== null).length);
|
||||
let statusContextCanIgnore = $derived(statusContextTarget?.files.some(isIgnoreableNewFile) ?? false);
|
||||
let statusContextCanStopTracking = $derived(statusContextTarget?.files.some(isTrackedStatusFile) ?? false);
|
||||
let statusContextIgnoreExtension = $derived(statusContextTarget?.kind === "file" ? statusContextExtension(statusContextTarget.label) : "");
|
||||
let statusContextIgnoreFolder = $derived(statusContextTarget ? statusContextFolder(statusContextTarget) : "");
|
||||
|
||||
$effect(() => {
|
||||
const validKeys = new Set(changedFiles.map(fileKey));
|
||||
@@ -517,7 +576,7 @@
|
||||
</section>
|
||||
|
||||
{#if statusContextTarget}
|
||||
<div class="status-context-menu" style={`left: ${statusContextMenuX}px; top: ${statusContextMenuY}px;`} role="menu" tabindex="-1" aria-label={`Actions for ${statusContextTarget.label}`} onclick={(event) => event.stopPropagation()} onkeydown={(event) => { if (event.key === "Escape") closeStatusContextMenu(); event.stopPropagation(); }}>
|
||||
<div bind:this={statusContextMenuElement} class="status-context-menu" style={`left: ${statusContextMenuX}px; top: ${statusContextMenuY}px;`} role="menu" tabindex="-1" aria-label={`Actions for ${statusContextTarget.label}`} onclick={(event) => event.stopPropagation()} onkeydown={(event) => { if (event.key === "Escape") closeStatusContextMenu(); event.stopPropagation(); }}>
|
||||
<div class="status-context-label">
|
||||
<span class="status-context-object-icon" aria-hidden="true">
|
||||
{#if statusContextTarget.kind === "folder"}<FolderOpen size={16} />{:else}<FileDiff size={16} />{/if}
|
||||
@@ -547,6 +606,49 @@
|
||||
<span>Save {statusContextTarget.files.length === 1 ? "this file" : `${statusContextTarget.files.length} files`} for later</span>
|
||||
</span>
|
||||
</button>
|
||||
{#if statusContextCanIgnore || statusContextCanStopTracking}
|
||||
<div class="menu-separator" role="separator"></div>
|
||||
{/if}
|
||||
{#if statusContextCanStopTracking}
|
||||
<button type="button" role="menuitem" onclick={runStatusContextStopTracking} disabled={isBusy} title="Keep the working-tree content and remove it from the Git index">
|
||||
<span class="status-context-action-icon untrack" aria-hidden="true">
|
||||
{#if statusContextTarget.kind === "folder"}<FolderMinus size={15} />{:else}<FileMinus2 size={15} />{/if}
|
||||
</span>
|
||||
<span class="status-context-action-copy">
|
||||
<strong>Stop tracking {statusContextTarget.kind}</strong>
|
||||
<span>Keep it on disk and remove it from Git</span>
|
||||
</span>
|
||||
</button>
|
||||
{/if}
|
||||
{#if statusContextCanIgnore}
|
||||
{#if statusContextTarget.kind === "file"}
|
||||
<button type="button" role="menuitem" onclick={() => runStatusContextIgnoreAction("file")} disabled={isBusy} title={`Add /${statusContextTarget.label.replace(/\\/g, "/")} to .gitignore`}>
|
||||
<span class="status-context-action-icon ignore" aria-hidden="true"><FileX size={15} /></span>
|
||||
<span class="status-context-action-copy">
|
||||
<strong>Ignore file</strong>
|
||||
<span>Add only this file to .gitignore</span>
|
||||
</span>
|
||||
</button>
|
||||
{/if}
|
||||
{#if statusContextIgnoreExtension}
|
||||
<button type="button" role="menuitem" onclick={() => runStatusContextIgnoreAction("extension")} disabled={isBusy} title={`Add *.${statusContextIgnoreExtension} to .gitignore`}>
|
||||
<span class="status-context-action-icon ignore" aria-hidden="true"><FileType size={15} /></span>
|
||||
<span class="status-context-action-copy">
|
||||
<strong>Ignore all *.{statusContextIgnoreExtension} files</strong>
|
||||
<span>Match this file type repository-wide</span>
|
||||
</span>
|
||||
</button>
|
||||
{/if}
|
||||
{#if statusContextTarget.kind === "folder" && statusContextIgnoreFolder}
|
||||
<button type="button" role="menuitem" onclick={() => runStatusContextIgnoreAction("folder")} disabled={isBusy} title={`Add /${statusContextIgnoreFolder}/ to .gitignore`}>
|
||||
<span class="status-context-action-icon ignore" aria-hidden="true"><FolderX size={15} /></span>
|
||||
<span class="status-context-action-copy">
|
||||
<strong>Ignore folder</strong>
|
||||
<span>Add this folder and its contents to .gitignore</span>
|
||||
</span>
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user