<script lang="ts">
import { cubicOut } from "svelte/easing";
import { slide } from "svelte/transition";
import type { TreeEntry } from "../../../api/types.js";
import type { FileStatusKind } from "../../../api/types/fleet.js";
import { explorer } from "../../../stores/explorer.svelte.js";
import { workbench } from "../../../stores/workbench.svelte.js";
import { commands } from "../../../stores/commands.svelte.js";
import { issues } from "../../../stores/issues.svelte.js";
import { activityBar } from "../../../stores/activity-bar.svelte.js";
import { ui } from "../../../stores/ui.svelte.js";
import { git } from "../../../stores/git.svelte.js";
import { conflicts } from "../../../stores/conflicts.svelte.js";
import { notes as notesStore } from "../../../stores/notes.svelte.js";
import { github } from "../../../stores/github.svelte.js";
import { notifications } from "../../../stores/notifications.svelte.js";
import { copyToClipboard } from "../../../utils/clipboard.js";
import { createProgressiveMount } from "../../../virtual/createProgressiveMount.svelte.js";
import AppIcon from "../../layout/AppIcon.svelte";
import IssueCreate from "../../issues/IssueCreate.svelte";
import GitCommitDialog from "./GitCommitDialog.svelte";
import GitCheckoutDialog from "./GitCheckoutDialog.svelte";
import GitDiscardDialog from "./GitDiscardDialog.svelte";
import GitBranchDeleteDialog from "./GitBranchDeleteDialog.svelte";
import ContextSubmenu from "../../menu/ContextSubmenu.svelte";
import { contextMenu as placeMenu } from "../../../utils/menu.js";
import GitBranchCreateDialog from "./GitBranchCreateDialog.svelte";
import ExplorerNode from "./ExplorerNode.svelte";
interface Props {
entry: TreeEntry;
depth?: number;
}
let { entry, depth = 0 }: Props = $props();
let expanded = $derived(
entry.kind === "directory" && explorer.isExpanded(entry.path),
);
let branchEntries = $derived(
entry.kind === "directory" ? (explorer.treeEntries.get(entry.path) ?? null) : null,
);
let branchStatus = $derived(
entry.kind === "directory" ? (explorer.treeStatus.get(entry.path) ?? null) : null,
);
let visibleBranchEntries = $derived(
branchEntries ? explorer.visibleEntries(branchEntries) : [],
);
const CHILD_BATCH = 24;
const childMount = createProgressiveMount(CHILD_BATCH);
let visibleChildSlice = $derived(visibleBranchEntries.slice(0, childMount.count));
let childDripping = $derived(childMount.settling);
$effect(() => {
childMount.setTarget(expanded ? visibleBranchEntries.length : 0);
});
$effect(() => () => childMount.dispose());
let isHidden = $derived(entry.hidden);
let submodule = $derived(entry.submodule);
let submoduleError = $derived(submodule?.error ?? null);
let loadingBranch = $derived(
entry.kind === "directory" && !!branchStatus?.loading && !branchEntries,
);
let contextMenu = $state<{ x: number; y: number } | null>(null);
let menuElement = $state<HTMLElement>();
let rowRef = $state<HTMLDivElement | undefined>(undefined);
let showIssueCreate = $state(false);
let showCommitDialog = $state(false);
let showCheckoutDialog = $state(false);
let showBranchCreateDialog = $state(false);
let showDiscardDialog = $state(false);
let showBranchDeleteDialog = $state(false);
let isDragOver = $state(false);
let showNewFileInput = $state(false);
let showNewDirInput = $state(false);
let showRenameInput = $state(false);
let newEntryName = $state("");
let renameValue = $state("");
let isActive = $derived(workbench.activeFilePath === entry.path);
let nodeGitStatus = $derived<FileStatusKind | null>(entry.git_status ?? null);
let repoGitStatus = $derived(entry.repo_status ?? null);
let repoHasVisibleChanges = $derived(
repoGitStatus ? repoGitStatus.files.some((f) => f.status !== "ignored") : false,
);
let hasNotes = $derived(notesStore.hasNotes(entry.path));
let openIssues = $derived(
submodule?.github_repo ? issues.openCount(submodule.github_repo) : 0,
);
let repoMirror = $derived(
submodule?.github_repo ? github.overview[submodule.github_repo] ?? null : null,
);
let openPulls = $derived(repoMirror?.open_pulls ?? 0);
let repoChangeCount = $derived(
repoGitStatus ? repoGitStatus.files.filter((f) => f.status !== "ignored").length : 0,
);
let repoDetached = $derived(repoGitStatus?.is_detached === true);
let repoBranch = $derived(repoGitStatus?.branch ?? null);
let repoAbsent = $derived(!!submodule && repoGitStatus?.exists === false);
let conflictCount = $derived(
submodule
? conflicts.forRepository(entry.path)?.conflicts ?? 0
: entry.kind === "directory"
? conflicts.underPath(entry.path)
: 0,
);
let peerPending = $derived(
submodule
? {
incoming: conflicts.forRepository(entry.path)?.incoming ?? 0,
outgoing: conflicts.forRepository(entry.path)?.outgoing ?? 0,
}
: entry.kind === "directory"
? conflicts.pendingUnderPath(entry.path)
: { incoming: 0, outgoing: 0 },
);
let peerNames = $derived(
submodule ? (conflicts.forRepository(entry.path)?.peers ?? []) : [],
);
let badgesEmpty = $derived.by(() => {
if (hasNotes) return false;
if (openIssues > 0) return false;
if (openPulls > 0) return false;
if (peerPending.incoming > 0 || peerPending.outgoing > 0) return false;
if (!submodule) return true;
if (submoduleError) return false;
if (!repoGitStatus) return true;
const hasBranch = repoGitStatus.branch && repoGitStatus.branch !== "main" && repoGitStatus.branch !== "master";
return (
!hasBranch &&
repoGitStatus.ahead === 0 &&
repoGitStatus.behind === 0 &&
repoChangeCount === 0 &&
!repoDetached
);
});
let gitStatusClass = $derived.by(() => {
if (submodule) return repoHasVisibleChanges ? "git-modified" : "";
if (!nodeGitStatus) return "";
return `git-${nodeGitStatus}`;
});
$effect(() => {
if (!isActive || !rowRef) return;
rowRef.scrollIntoView({ block: "nearest" });
});
async function openEntry() {
if (entry.kind === "directory") {
await explorer.toggleDirectory(entry.path);
return;
}
void explorer.ensureFile(entry.path);
workbench.openFile(entry.path, true);
}
function promoteFileTab() {
if (entry.kind !== "file") return;
workbench.openFile(entry.path, false);
}
function openNodeInfo() {
workbench.openNodeInfo(entry.path, true);
}
function openNodeInfoPermanent() {
workbench.openNodeInfo(entry.path, false);
}
function handleContextMenu(e: MouseEvent) {
e.preventDefault();
contextMenu = { x: e.clientX, y: e.clientY };
}
function closeContextMenu() {
contextMenu = null;
}
function handleRefactor() {
ui.refactorTarget = entry.path;
ui.activeModal = "refactor";
closeContextMenu();
}
function handlePrimaryAction() {
openEntry();
closeContextMenu();
}
async function handleCopyPath() {
try {
await copyToClipboard(entry.path);
} catch { }
closeContextMenu();
}
async function handleCopyName() {
try {
await copyToClipboard(entry.name);
} catch { }
closeContextMenu();
}
function handleCopyRelativeDir() {
const parts = entry.path.split("/");
parts.pop();
void copyToClipboard(parts.join("/") || ".").catch(() => {});
closeContextMenu();
}
async function handleUpdate() {
try {
await explorer.updateProject(entry.path);
} finally {
closeContextMenu();
}
}
function handleNewIssue() {
closeContextMenu();
showIssueCreate = true;
}
function handleResolveConflicts() {
closeContextMenu();
workbench.showPeers();
}
function handleShowIssues() {
closeContextMenu();
workbench.openNodeInfo(entry.path, false);
}
let hasDiff = $derived.by(() => {
if (submodule) return false;
const s = nodeGitStatus;
return s !== null && s !== "ignored";
});
let owningRepoPath = $derived(entry.repo_path ?? null);
let repoRelativePath = $derived(
owningRepoPath ? entry.path.slice(owningRepoPath.length + 1) : entry.path,
);
function handleShowDiff() {
closeContextMenu();
workbench.openDiff(repoRelativePath, owningRepoPath, false);
}
function handleShowChanges() {
closeContextMenu();
workbench.openNodeInfo(entry.path, false);
}
function handleGitStage() {
closeContextMenu();
git.stage(entry.path);
}
function handleGitCommit() {
closeContextMenu();
showCommitDialog = true;
}
function handleGitPush() {
closeContextMenu();
git.push(entry.path);
}
function handleGitPull() {
closeContextMenu();
git.pull(entry.path);
}
function handleGitCheckout() {
closeContextMenu();
showCheckoutDialog = true;
}
function handleGitCreateBranch() {
closeContextMenu();
showBranchCreateDialog = true;
}
function handleGitFetch() {
closeContextMenu();
git.fetch(entry.path);
}
function handleGitDiscard() {
closeContextMenu();
showDiscardDialog = true;
}
function handleGitDeleteBranch() {
closeContextMenu();
showBranchDeleteDialog = true;
}
function handleOpenRepoPage() {
closeContextMenu();
workbench.openNodeInfo(entry.path, false);
}
function handleNewFile() {
closeContextMenu();
if (!expanded && entry.kind === "directory") {
void explorer.expandDirectory(entry.path);
}
newEntryName = "";
showNewFileInput = true;
showNewDirInput = false;
}
function handleNewDirectory() {
closeContextMenu();
if (!expanded && entry.kind === "directory") {
void explorer.expandDirectory(entry.path);
}
newEntryName = "";
showNewDirInput = true;
showNewFileInput = false;
}
function handleRename() {
closeContextMenu();
renameValue = entry.name;
showRenameInput = true;
}
function handleFileCopy() {
explorer.copyToClipboard(entry.path);
closeContextMenu();
}
function handleFileCut() {
explorer.cutToClipboard(entry.path);
closeContextMenu();
}
function handlePaste() {
closeContextMenu();
void explorer.pasteEntry(entry.path).catch((err: unknown) => {
notifications.upsert(
`paste-error-${Date.now()}`,
"error",
"Paste failed",
err instanceof Error ? err.message : String(err),
"worktree",
);
});
}
async function handleDelete() {
closeContextMenu();
const isDir = entry.kind === "directory";
const label = isDir ? `directory "${entry.name}"` : `file "${entry.name}"`;
const confirmed = window.confirm(`Delete ${label}? This cannot be undone.`);
if (!confirmed) return;
try {
await explorer.deleteEntry(entry.path, isDir);
} catch (err) {
notifications.upsert(
`delete-error-${Date.now()}`,
"error",
"Delete failed",
err instanceof Error ? err.message : String(err),
"worktree",
);
}
}
async function confirmNewEntry(kind: "file" | "dir") {
const name = newEntryName.trim();
showNewFileInput = false;
showNewDirInput = false;
if (!name) return;
try {
if (kind === "file") {
await explorer.createFile(entry.path, name);
} else {
await explorer.createDirectory(entry.path, name);
}
} catch (err) {
notifications.upsert(
`create-error-${Date.now()}`,
"error",
kind === "file" ? "Create file failed" : "Create directory failed",
err instanceof Error ? err.message : String(err),
"worktree",
);
}
}
function cancelNewEntry() {
showNewFileInput = false;
showNewDirInput = false;
newEntryName = "";
}
async function confirmRename() {
const name = renameValue.trim();
showRenameInput = false;
if (!name || name === entry.name) return;
try {
await explorer.renameEntry(entry.path, name);
} catch (err) {
notifications.upsert(
`rename-error-${Date.now()}`,
"error",
"Rename failed",
err instanceof Error ? err.message : String(err),
"worktree",
);
}
}
function cancelRename() {
showRenameInput = false;
renameValue = "";
}
function handleDragStart(e: DragEvent) {
e.dataTransfer?.setData("text/plain", entry.path);
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = "copyMove";
}
}
function handleDragOver(e: DragEvent) {
if (entry.kind !== "directory") return;
const draggedPath = e.dataTransfer?.getData("text/plain");
if (draggedPath === entry.path) return;
e.preventDefault();
if (e.dataTransfer) {
e.dataTransfer.dropEffect = e.altKey ? "copy" : "move";
}
isDragOver = true;
}
function handleDragLeave(e: DragEvent) {
const related = e.relatedTarget as Node | null;
if (rowRef && related && rowRef.contains(related)) return;
isDragOver = false;
}
async function handleDrop(e: DragEvent) {
e.preventDefault();
isDragOver = false;
if (entry.kind !== "directory") return;
const fromPath = e.dataTransfer?.getData("text/plain");
if (!fromPath || fromPath === entry.path) return;
const name = fromPath.split("/").pop() ?? fromPath;
const toPath = `${entry.path}/${name}`;
try {
if (e.altKey) {
await explorer.copyEntry(fromPath, toPath);
} else {
await explorer.moveEntry(fromPath, toPath);
}
} catch (err) {
notifications.upsert(
`drop-error-${Date.now()}`,
"error",
e.altKey ? "Copy failed" : "Move failed",
err instanceof Error ? err.message : String(err),
"worktree",
);
}
}
function handleDragEnd() {
isDragOver = false;
}
$effect(() => {
if (!contextMenu) return;
function handler(event: Event) {
const target = event.target;
if (target instanceof Node && menuElement?.contains(target)) return;
contextMenu = null;
}
const id = setTimeout(() => {
document.addEventListener("click", handler);
document.addEventListener("contextmenu", handler);
}, 0);
return () => {
clearTimeout(id);
document.removeEventListener("click", handler);
document.removeEventListener("contextmenu", handler);
};
});
$effect(() => {
if (!contextMenu) return;
function handler(e: KeyboardEvent) {
if (e.key === "Escape") {
contextMenu = null;
}
}
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
});
let hasClipboard = $derived(explorer.clipboard !== null);
</script>
<div class="node" style:--depth={`${depth}`}>
<div
bind:this={rowRef}
class:hidden={isHidden}
class:active={isActive}
class:drag-over={isDragOver}
class="node-row {gitStatusClass}"
draggable={true}
oncontextmenu={handleContextMenu}
ondragstart={handleDragStart}
ondragover={handleDragOver}
ondragleave={handleDragLeave}
ondrop={handleDrop}
ondragend={handleDragEnd}
>
{#if showRenameInput}
<div class="node-zone node-zone-left node-rename-zone">
<input
class="node-inline-input"
type="text"
bind:value={renameValue}
autofocus
onkeydown={(e) => {
if (e.key === "Enter") { e.preventDefault(); void confirmRename(); }
if (e.key === "Escape") { e.preventDefault(); cancelRename(); }
}}
onblur={cancelRename}
/>
</div>
{:else}
<button
class="node-zone node-zone-left"
onclick={openEntry}
ondblclick={entry.kind !== "directory" ? promoteFileTab : undefined}
aria-label={entry.kind === "directory" ? (expanded ? "Collapse" : "Expand") : "Open"}
>
<span class="node-twist">
{#if entry.kind === "directory"}
<AppIcon
class={`${expanded ? "expanded" : ""} ${loadingBranch ? "node-spinner" : ""}`.trim()}
name={loadingBranch ? "refresh" : "chevron-right"}
size={10}
/>
{/if}
</span>
<span
class="node-icon"
title={submodule
? repoMirror?.private === true
? "Private GitHub repository"
: repoMirror?.private === false
? "Public GitHub repository"
: "GitHub repository"
: undefined}
>
{#if entry.kind === "directory"}
<AppIcon
name={submodule
? repoMirror?.private === true
? "globe-lock"
: "globe"
: expanded
? "folder-open"
: "folder"}
size={14}
/>
{:else}
<AppIcon name="file-text" size={14} />
{/if}
</span>
<span class="node-label">
<span class:node-name--submodule={!!submodule} class="node-name">{entry.name}</span>
{#if submodule?.version}
<span class="node-version" title={`Version ${submodule.version}`}>
v{submodule.version}
</span>
{/if}
</span>
</button>
{/if}
<button
class="node-zone node-zone-badges"
onclick={openNodeInfo}
ondblclick={openNodeInfoPermanent}
title="Open node info"
aria-label="Open node info"
>
{#if submodule}
<span class="node-meta">
{#if submoduleError}
<span class="node-error-badge" title={submoduleError}>
<AppIcon name="triangle-alert" size={11} />
</span>
{/if}
{#if repoAbsent}
<span class="node-absent" title="Declared by .gitmodules and not checked out here">absent</span>
{:else if repoGitStatus}
{#if repoDetached}
<span class="node-detached" title="HEAD is detached — commits here belong to no branch">detached</span>
{:else if repoGitStatus.branch && repoGitStatus.branch !== "main" && repoGitStatus.branch !== "master"}
<span class="node-branch">{repoGitStatus.branch}</span>
{/if}
{#if repoChangeCount > 0}
<span class="node-dirty" title="{repoChangeCount} uncommitted change{repoChangeCount === 1 ? '' : 's'}">
{repoChangeCount}<AppIcon name="file-diff" size={10} />
</span>
{/if}
{#if repoGitStatus.ahead > 0}
<span class="node-ahead" title="{repoGitStatus.ahead} commit(s) to push">
{repoGitStatus.ahead}<AppIcon name="arrow-up" size={10} />
</span>
{/if}
{#if repoGitStatus.behind > 0}
<span class="node-behind" title="{repoGitStatus.behind} commit(s) to pull">
{repoGitStatus.behind}<AppIcon name="arrow-down" size={10} />
</span>
{/if}
{/if}
</span>
{/if}
{#if peerPending.incoming > 0}
<span
class="node-peer-in"
title={peerNames.length > 0
? `${peerPending.incoming} change(s) waiting to arrive from ${peerNames.join(", ")}`
: `${peerPending.incoming} change(s) waiting to arrive from a paired machine`}
>{peerPending.incoming}<AppIcon name="arrow-left" size={10} /></span>
{/if}
{#if peerPending.outgoing > 0}
<span
class="node-peer-out"
title={peerNames.length > 0
? `${peerPending.outgoing} change(s) waiting to go to ${peerNames.join(", ")}`
: `${peerPending.outgoing} change(s) waiting to go to a paired machine`}
>{peerPending.outgoing}<AppIcon name="arrow-right" size={10} /></span>
{/if}
{#if repoMirror?.checks && repoMirror.checks !== "neutral"}
<span
class="node-checks node-checks--{repoMirror.checks}"
title="CI on this machine's commit: {repoMirror.checks}"
><AppIcon name="circle" size={8} /></span>
{/if}
{#if openPulls > 0}
<span
class="node-pulls"
title="{openPulls} open pull request{openPulls === 1 ? '' : 's'}"
>
<AppIcon name="git-merge" size={11} />
{openPulls}
</span>
{/if}
{#if openIssues > 0}
<span
class="node-issues-badge"
title="{openIssues} open issue{openIssues === 1 ? '' : 's'} — open this node to read them"
>
<AppIcon name="circle-alert" size={11} />
{openIssues}
</span>
{/if}
{#if hasNotes}
<span class="node-notes-badge" title="Has notes">
<AppIcon name="message-circle" size={11} />
</span>
{/if}
{#if badgesEmpty}
<span class="node-info-hint" title="Open node info">
<AppIcon name="info" size={11} />
</span>
{/if}
</button>
</div>
{#if entry.kind === "directory" && expanded}
<div
class="children"
transition:slide={{
axis: "y",
duration: 200,
easing: cubicOut,
}}
>
{#if branchStatus?.error}
<div class="node-error">{branchStatus.error}</div>
{/if}
{#if branchEntries}
{#each visibleChildSlice as child (child.path)}
<ExplorerNode entry={child} depth={depth + 1} />
{/each}
{#if childDripping}
<div class="children-loading" aria-hidden="true"></div>
{/if}
{/if}
{#if showNewFileInput}
<div class="node-inline-entry" style:--depth={`${depth + 1}`}>
<span class="node-inline-icon"><AppIcon name="file-text" size={14} /></span>
<input
class="node-inline-input"
type="text"
placeholder="file name"
bind:value={newEntryName}
autofocus
onkeydown={(e) => {
if (e.key === "Enter") { e.preventDefault(); void confirmNewEntry("file"); }
if (e.key === "Escape") { e.preventDefault(); cancelNewEntry(); }
}}
onblur={cancelNewEntry}
/>
</div>
{/if}
{#if showNewDirInput}
<div class="node-inline-entry" style:--depth={`${depth + 1}`}>
<span class="node-inline-icon"><AppIcon name="folder" size={14} /></span>
<input
class="node-inline-input"
type="text"
placeholder="folder name"
bind:value={newEntryName}
autofocus
onkeydown={(e) => {
if (e.key === "Enter") { e.preventDefault(); void confirmNewEntry("dir"); }
if (e.key === "Escape") { e.preventDefault(); cancelNewEntry(); }
}}
onblur={cancelNewEntry}
/>
</div>
{/if}
</div>
{/if}
</div>
{#if contextMenu}
{@const editorContext = entry.kind === "file" ? explorer.editorContextForPath(entry.path) : null}
{@const extensionMenuItems = explorer.menuCommands("explorer/context", editorContext)}
<div
class="context-menu"
bind:this={menuElement}
use:placeMenu={contextMenu}
>
<button class="context-menu-item" onclick={handlePrimaryAction}>
{entry.kind === "directory" ? expanded ? "Collapse" : "Expand" : "Open"}
</button>
{#if entry.kind === "directory"}
<div class="context-menu-separator"></div>
<button class="context-menu-item" onclick={handleNewFile}>New File...</button>
<button class="context-menu-item" onclick={handleNewDirectory}>New Folder...</button>
<button class="context-menu-item" onclick={handleRefactor}>Refactor...</button>
{/if}
<div class="context-menu-separator"></div>
<button class="context-menu-item" onclick={handleCopyPath}>Copy Path</button>
<button class="context-menu-item" onclick={handleCopyName}>Copy Name</button>
<button class="context-menu-item" onclick={handleCopyRelativeDir}>Copy Containing Folder Path</button>
<div class="context-menu-separator"></div>
<button class="context-menu-item" onclick={handleRename}>Rename...</button>
<button class="context-menu-item" onclick={handleFileCopy}>Copy</button>
<button class="context-menu-item" onclick={handleFileCut}>Cut</button>
{#if entry.kind === "directory" && hasClipboard}
<button class="context-menu-item" onclick={handlePaste}>
Paste{explorer.clipboard ? ` "${explorer.clipboard.path.split("/").pop()}"` : ""}
</button>
{/if}
<div class="context-menu-separator"></div>
<button class="context-menu-item context-menu-item--danger" onclick={handleDelete}>Delete</button>
{#if hasDiff}
<div class="context-menu-separator"></div>
<button class="context-menu-item" onclick={handleShowDiff}>Show Diff</button>
{/if}
{#if conflictCount > 0}
<div class="context-menu-separator"></div>
<button class="context-menu-item context-menu-item--danger" onclick={handleResolveConflicts}>
Resolve {conflictCount} Conflict{conflictCount === 1 ? "" : "s"}...
</button>
{/if}
{#if submodule}
<div class="context-menu-separator"></div>
{#if repoAbsent}
<button class="context-menu-item" onclick={handleUpdate}>
Check out this repository
</button>
<div class="context-menu-note">
Declared and not checked out. Nothing else can act on it until it is.
</div>
{:else}
<ContextSubmenu label="Repository" detail={repoBranch ?? ""}>
<button class="context-menu-item" onclick={handleOpenRepoPage}>
Open repository page
</button>
<button class="context-menu-item" onclick={handleShowChanges} disabled={repoChangeCount === 0}>
Changes ({repoChangeCount})
</button>
<div class="context-menu-separator"></div>
<div class="context-menu-label">Record</div>
<button class="context-menu-item" onclick={handleGitStage} disabled={git.operationInProgress !== null}>
Stage all
</button>
<button class="context-menu-item" onclick={handleGitCommit} disabled={git.operationInProgress !== null}>
Commit…
</button>
<div class="context-menu-separator"></div>
<div class="context-menu-label">Exchange</div>
<button class="context-menu-item" onclick={handleGitFetch} disabled={git.operationInProgress !== null}>
Fetch
</button>
<button class="context-menu-item" onclick={handleGitPull} disabled={git.operationInProgress !== null}>
Pull
</button>
<button class="context-menu-item" onclick={handleGitPush} disabled={git.operationInProgress !== null}>
Push
</button>
<div class="context-menu-separator"></div>
<div class="context-menu-label">Branch</div>
<button class="context-menu-item" onclick={handleGitCheckout} disabled={git.operationInProgress !== null}>
Checkout…
</button>
<button class="context-menu-item" onclick={handleGitCreateBranch} disabled={git.operationInProgress !== null}>
Create branch…
</button>
<button class="context-menu-item" onclick={handleGitDeleteBranch} disabled={git.operationInProgress !== null}>
Delete branch…
</button>
<div class="context-menu-separator"></div>
<button class="context-menu-item" onclick={handleUpdate}>
Refresh
</button>
<button
class="context-menu-item context-menu-item--danger"
onclick={handleGitDiscard}
disabled={git.operationInProgress !== null}
>
Discard changes…
</button>
</ContextSubmenu>
{#if submodule.github_repo}
<ContextSubmenu label="GitHub" detail={submodule.github_repo}>
<button class="context-menu-item" onclick={handleShowIssues}>Issues</button>
<button class="context-menu-item" onclick={handleNewIssue}>New issue…</button>
</ContextSubmenu>
{/if}
{/if}
{/if}
{#if extensionMenuItems.length > 0}
<div class="context-menu-separator"></div>
{#each extensionMenuItems as item (item.command)}
<button class="context-menu-item" onclick={() => { void commands.execute(item.command); closeContextMenu(); }}>
{item.title}
</button>
{/each}
{/if}
</div>
{/if}
{#if showIssueCreate && submodule?.github_repo}
<IssueCreate
repos={[submodule.github_repo]}
onclose={() => (showIssueCreate = false)}
/>
{/if}
{#if showCommitDialog}
<GitCommitDialog
repoPath={entry.path}
onclose={() => (showCommitDialog = false)}
/>
{/if}
{#if showDiscardDialog}
<GitDiscardDialog
repoPath={entry.path}
label={entry.name}
onclose={() => (showDiscardDialog = false)}
/>
{/if}
{#if showBranchDeleteDialog}
<GitBranchDeleteDialog
repoPath={entry.path}
onclose={() => (showBranchDeleteDialog = false)}
/>
{/if}
{#if showCheckoutDialog}
<GitCheckoutDialog
repoPath={entry.path}
onclose={() => (showCheckoutDialog = false)}
/>
{/if}
{#if showBranchCreateDialog}
<GitBranchCreateDialog
repoPath={entry.path}
onclose={() => (showBranchCreateDialog = false)}
/>
{/if}
<style>
.node {
--indent: calc(var(--depth) * var(--wb-worktree-indent-step));
}
.node-row {
width: 100%;
display: flex;
align-items: stretch;
color: var(--vscode-editor-foreground);
font-size: var(--wb-font-size-12);
}
.node-row.hidden {
color: var(--vscode-disabledForeground);
}
.node-row.active {
background: var(--vscode-list-activeSelectionBackground, rgba(83, 153, 236, 0.2));
}
.node-row.drag-over {
background: var(--vscode-list-dropBackground, rgba(83, 153, 236, 0.18));
outline: 1px solid var(--vscode-focusBorder, #4daafc);
outline-offset: -1px;
}
.node-zone {
display: flex;
align-items: center;
padding-top: var(--wb-spacing-6);
padding-bottom: var(--wb-spacing-6);
text-align: left;
background: transparent;
color: inherit;
font-size: inherit;
cursor: pointer;
}
.node-zone:hover {
background: var(--vscode-list-hoverBackground);
}
.node-zone-left {
flex: 1;
min-width: 0;
padding-left: calc(var(--wb-spacing-10) + var(--indent));
padding-right: var(--wb-spacing-8);
gap: var(--wb-spacing-4);
}
.node-rename-zone {
flex: 1;
min-width: 0;
padding-left: calc(var(--wb-spacing-10) + var(--indent));
padding-right: var(--wb-spacing-8);
display: flex;
align-items: center;
cursor: default;
}
.node-pulls {
display: inline-flex;
align-items: center;
gap: var(--wb-spacing-2);
font-size: var(--wb-font-size-10);
color: var(--vscode-descriptionForeground);
}
.node-checks {
font-size: var(--wb-font-size-9);
line-height: 1;
}
.node-checks--success { color: var(--vscode-testing-iconPassed, #2d7d46); }
.node-checks--failure { color: var(--vscode-errorForeground); }
.node-checks--pending { color: var(--vscode-editorWarning-foreground, #cca700); }
.node-issues-badge {
display: inline-flex;
align-items: center;
gap: var(--wb-spacing-2);
font-size: var(--wb-font-size-10);
color: var(--vscode-editorWarning-foreground, #cca700);
}
.node-zone-badges {
flex-shrink: 0;
min-width: var(--wb-control-size-24);
padding-left: var(--wb-spacing-4);
padding-right: var(--wb-spacing-8);
gap: var(--wb-spacing-4);
}
.node-row:has(.node-zone-badges:hover) .node-zone-left {
background: transparent;
}
.node-row.git-modified .node-name,
.node-row.git-modified .node-icon {
color: #cca700;
}
.node-row.git-staged .node-name,
.node-row.git-staged .node-icon {
color: #73c991;
}
.node-row.git-untracked .node-name,
.node-row.git-untracked .node-icon {
color: #73c991;
}
.node-row.git-ignored .node-name,
.node-row.git-ignored .node-icon {
color: var(--vscode-disabledForeground);
}
.node-row.git-deleted .node-name,
.node-row.git-deleted .node-icon {
color: #f14c4c;
text-decoration: line-through;
}
.node-row.git-renamed .node-name,
.node-row.git-renamed .node-icon {
color: #73c991;
}
.node-row.git-conflict .node-name,
.node-row.git-conflict .node-icon {
color: #f14c4c;
font-weight: 700;
}
.node-twist {
width: var(--wb-control-size-10);
color: var(--vscode-disabledForeground);
flex-shrink: 0;
}
.node-twist :global(svg.expanded) {
transform: rotate(90deg);
}
.node-twist :global(svg.node-spinner) {
animation: node-spin 0.7s linear infinite;
}
.node-icon {
width: var(--wb-control-size-14);
height: var(--wb-control-size-14);
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: var(--wb-font-size-12);
}
.node-label {
min-width: 0;
flex: 1;
display: flex;
flex-direction: column;
align-items: flex-start;
line-height: 1.2;
}
.node-name {
min-width: 0;
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: block;
}
.node-name--submodule {
font-weight: 650;
}
.node-version {
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--vscode-disabledForeground);
font-size: var(--wb-font-size-10);
font-weight: 400;
line-height: 1.2;
opacity: 0.82;
}
.node-meta {
display: inline-flex;
align-items: center;
gap: var(--wb-spacing-6);
color: var(--vscode-disabledForeground);
font-size: var(--wb-font-size-11);
}
.node-branch {
color: var(--vscode-disabledForeground);
max-width: var(--wb-control-size-100);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.node-dirty,
.node-ahead,
.node-behind,
.node-peer-in,
.node-peer-out {
display: inline-flex;
align-items: center;
gap: 1px;
}
.node-checks :global(svg) {
fill: currentColor;
}
.node-peer-in,
.node-peer-out {
font-size: 10px;
flex: none;
color: var(--vscode-charts-purple, #b180d7);
}
.node-ahead,
.node-behind {
font-variant-numeric: tabular-nums;
font-size: var(--wb-font-size-10);
}
.node-ahead {
color: #73c991;
}
.node-behind {
color: #f14c4c;
}
.context-menu-note {
padding: var(--wb-spacing-6) var(--wb-spacing-12);
max-width: 240px;
font-size: var(--wb-font-size-11);
line-height: 1.4;
color: var(--vscode-disabledForeground);
}
.node-dirty {
font-variant-numeric: tabular-nums;
font-size: var(--wb-font-size-10);
color: var(--vscode-editorWarning-foreground, #cca700);
}
.node-absent,
.node-detached {
font-size: var(--wb-font-size-10);
color: var(--vscode-errorForeground, #f14c4c);
}
.node-absent {
color: var(--vscode-disabledForeground);
font-style: italic;
}
.node-notes-badge {
color: var(--vscode-textLink-foreground, #4daafc);
display: inline-flex;
align-items: center;
opacity: 0.85;
}
.node-info-hint {
display: none;
align-items: center;
color: var(--vscode-disabledForeground);
opacity: 0.5;
}
.node-row:hover .node-info-hint {
display: inline-flex;
}
.node-row.hidden .node-icon,
.node-row.hidden .node-name,
.node-row.hidden .node-version {
color: var(--vscode-disabledForeground);
}
.children {
display: flex;
flex-direction: column;
}
.children-loading {
height: var(--wb-control-size-2);
background: var(--vscode-progressBar-background, #0e70c0);
opacity: 0.5;
margin: var(--wb-spacing-1) 0;
}
.node-error {
padding: var(--wb-spacing-6) var(--wb-spacing-10) var(--wb-spacing-6) calc(var(--wb-spacing-34) + var(--indent));
font-size: var(--wb-font-size-11);
color: var(--vscode-errorForeground);
}
.node-row:has(.node-error-badge) {
color: var(--vscode-errorForeground);
}
.node-error-badge {
display: inline-flex;
align-items: center;
color: #e09030;
}
.node-inline-entry {
display: flex;
align-items: center;
gap: var(--wb-spacing-4);
padding: var(--wb-spacing-3) var(--wb-spacing-8) var(--wb-spacing-3) calc(var(--wb-spacing-10) + calc(var(--depth) * var(--wb-spacing-14)));
}
.node-inline-icon {
width: var(--wb-control-size-14);
flex-shrink: 0;
color: var(--vscode-disabledForeground);
}
.node-inline-input {
flex: 1;
min-width: 0;
background: var(--vscode-input-background, #3c3c3c);
color: var(--vscode-input-foreground, #cccccc);
border: 1px solid var(--vscode-focusBorder, #007acc);
border-radius: var(--wb-corner-radius-2);
padding: var(--wb-spacing-1) var(--wb-spacing-4);
font-size: var(--wb-font-size-12);
font-family: inherit;
outline: none;
}
.node-inline-input:focus {
border-color: var(--vscode-focusBorder, #007acc);
}
@keyframes node-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
</style>