<script lang="ts">
import { onMount } from "svelte";
import { contextMenu as placeMenu } from "../../../utils/menu.js";
import { explorer } from "../../../stores/explorer.svelte.js";
import { git } from "../../../stores/git.svelte.js";
import { conflicts } from "../../../stores/conflicts.svelte.js";
import { ui } from "../../../stores/ui.svelte.js";
import { workbench } from "../../../stores/workbench.svelte.js";
import { createProgressiveMount } from "../../../virtual/createProgressiveMount.svelte.js";
import { getFleetDetailedStatus } from "../../../api/client.js";
import type { RepoDetailedStatus } from "../../../api/types/fleet.js";
import { buildRepoTree, describeOrphans } from "./fleet-tree.js";
import AppIcon from "../../layout/AppIcon.svelte";
import GitCommitDialog from "./GitCommitDialog.svelte";
import GitCheckoutDialog from "./GitCheckoutDialog.svelte";
import GitMergeBackDialog from "./GitMergeBackDialog.svelte";
import GitBranchCreateDialog from "./GitBranchCreateDialog.svelte";
import ExplorerNode from "./ExplorerNode.svelte";
import SidebarHeader from "../SidebarHeader.svelte";
onMount(() => {
void explorer.ensureTree("");
void explorer.ensureWorkbenchModel();
void loadFleet();
});
// ── the worktree as a fleet of repositories ─────────────────────────────
//
// Every repository's own state is on its row in the tree. What has no row is
// the worktree ITSELF: the superproject, the totals across every repository
// at once, and a repository declared by a `.gitmodules` this response cannot
// place. That is what this strip is for, and it is deliberately the only
// thing in it — anything else here would repeat a row.
let fleet = $state<RepoDetailedStatus[]>([]);
let fleetError = $state<string | null>(null);
let fleetLoading = $state(false);
/// When these counts were read. A tree that says "3 dirty" says nothing
/// about WHEN unless it says when, and this panel is read after a build has
/// been running for ten minutes as often as it is read fresh.
let fleetReadAt = $state<Date | null>(null);
async function loadFleet() {
if (fleetLoading) return;
fleetLoading = true;
try {
const response = await getFleetDetailedStatus();
fleet = response.repos;
fleetReadAt = new Date();
fleetError = null;
} catch (failure) {
// The counts already read stay: a failed refresh has not committed,
// pushed or cleaned anything.
fleetError = failure instanceof Error ? failure.message : String(failure);
} finally {
fleetLoading = false;
}
}
// A git operation changes exactly what this strip reports, so it is read
// again when one finishes. Watched rather than wired into each button: an
// operation started from a tree row, the command palette or a dialog is the
// same operation.
// A plain variable, deliberately: nothing renders it, and state written by
// the same effect that reads it is how an effect re-runs itself.
let lastOperation: string | null = null;
$effect(() => {
const running = git.operationInProgress;
if (running !== null) {
lastOperation = running;
return;
}
if (lastOperation === null) return;
lastOperation = null;
void loadFleet();
});
let superproject = $derived(fleet.find((repo) => repo.is_superproject) ?? null);
let dirtyCount = $derived(fleet.filter((repo) => repo.exists && !repo.is_clean).length);
let aheadCount = $derived(fleet.filter((repo) => repo.ahead > 0).length);
let behindCount = $derived(fleet.filter((repo) => repo.behind > 0).length);
let absentCount = $derived(fleet.filter((repo) => !repo.exists).length);
let structureError = $derived(describeOrphans(buildRepoTree(fleet).orphans));
let worktreeConflicts = $derived(conflicts.here?.total ?? 0);
$effect(() => conflicts.watch());
// Read entries and status from separate maps so that loading-state transitions
// never invalidate the entry-derived chain (same pattern as ExplorerNode).
let rootEntries = $derived(explorer.treeEntries.get("") ?? null);
let rootStatus = $derived(explorer.treeStatus.get("") ?? null);
let visibleRootEntries = $derived(
rootEntries ? explorer.visibleEntries(rootEntries) : [],
);
// Progressive mounting: entries are admitted a few per frame so Svelte never
// creates all ExplorerNode instances in a single flush (stack
// overflow risk on large worktrees with 50+ entries).
//
// Rows already mounted stay mounted when the data is re-read: only the count
// moves, and only toward the current entry count. See createProgressiveMount.
// Sized so an ordinary directory lands in a single frame — what fits on
// screen should appear at once — while a very large one still streams in
// bounded pieces instead of building one enormous component subtree.
const BATCH = 24;
const mount = createProgressiveMount(BATCH);
let visibleSlice = $derived(visibleRootEntries.slice(0, mount.count));
let loadingEntries = $derived(mount.settling);
$effect(() => {
mount.setTarget(visibleRootEntries.length);
});
$effect(() => () => mount.dispose());
// The sidebar keeps every panel mounted and hides them with CSS, so the store
// cannot tell from the tree alone whether anyone is looking at it. While the
// explorer is hidden nothing is re-read; what changed meanwhile is read when
// it comes back.
$effect(() => {
explorer.setExplorerVisible(workbench.worktreeView === "explorer");
});
// Nothing is re-read while the panel is hidden, so what changed meanwhile is
// read when it comes back — the same rule the tree follows, applied to the
// line above it.
let wasVisible = false;
$effect(() => {
const visible = workbench.worktreeView === "explorer";
if (visible && !wasVisible) void loadFleet();
wasVisible = visible;
});
let contextMenu = $state<{ x: number; y: number } | null>(null);
let updating = $state(false);
let showFleetCommitDialog = $state(false);
let showFleetCheckoutDialog = $state(false);
let showMergeBackDialog = $state(false);
let showRootCommitDialog = $state(false);
let showRootCheckoutDialog = $state(false);
let showRootBranchDialog = $state(false);
/// The superproject, as git addresses it.
///
/// It is the one repository with no row in the tree — the tree IS its
/// working copy — so its own operations are here.
const ROOT_REPO = ".";
function portal(node: HTMLElement) {
document.body.appendChild(node);
return {
destroy() {
node.remove();
},
};
}
function handlePanelContextMenu(event: MouseEvent) {
if (event.target !== event.currentTarget) {
return;
}
event.preventDefault();
contextMenu = { x: event.clientX, y: event.clientY };
}
function handleRefactorWorktree() {
// The worktree root, as the daemon resolves a relative path against it —
// "." is the whole tree without this panel having to know where it lives.
ui.refactorTarget = ".";
ui.activeModal = "refactor";
contextMenu = null;
}
function handleToggleHidden() {
explorer.showHidden = !explorer.showHidden;
contextMenu = null;
}
function handleRefresh() {
// Refresh means the tree the user is looking at, not only its top level:
// every listing on screen is read again, in one request. The worktree's
// own line goes with it — a refresh that left the totals as they were
// would be a refresh that disagrees with the rows under it.
explorer.refreshLoadedListings();
void loadFleet();
contextMenu = null;
}
async function handleUpdate() {
updating = true;
try {
await explorer.updateProject("");
} finally {
updating = false;
contextMenu = null;
void loadFleet();
}
}
// The superproject's own git operations
function handleRootStage() {
contextMenu = null;
git.stage(ROOT_REPO);
}
function handleRootCommit() {
contextMenu = null;
showRootCommitDialog = true;
}
function handleRootPush() {
contextMenu = null;
git.push(ROOT_REPO);
}
function handleRootPull() {
contextMenu = null;
git.pull(ROOT_REPO);
}
function handleRootCheckout() {
contextMenu = null;
showRootCheckoutDialog = true;
}
function handleRootCreateBranch() {
contextMenu = null;
showRootBranchDialog = true;
}
function handleResolveConflicts() {
contextMenu = null;
// Conflicts between machines are settled on the home's peers panel.
workbench.showPeers();
}
function openPanelMenu(event: MouseEvent) {
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect();
contextMenu = { x: rect.left, y: rect.bottom + 2 };
}
// Fleet git operations
function handleFleetStageAll() {
contextMenu = null;
git.stageAll();
}
function handleFleetCommitAll() {
contextMenu = null;
showFleetCommitDialog = true;
}
function handleFleetPushAll() {
contextMenu = null;
git.pushAll();
}
function handleFleetPullAll() {
contextMenu = null;
git.pullAll();
}
function handleFleetCheckoutAll() {
contextMenu = null;
showFleetCheckoutDialog = true;
}
function handleFleetMergeBack() {
contextMenu = null;
showMergeBackDialog = true;
}
$effect(() => {
if (!contextMenu) return;
function handler() {
contextMenu = null;
}
const id = setTimeout(() => {
document.addEventListener("click", handler, { once: true });
document.addEventListener("contextmenu", handler, { once: true });
}, 0);
return () => {
clearTimeout(id);
document.removeEventListener("click", handler);
document.removeEventListener("contextmenu", handler);
};
});
</script>
<div class="panel">
<SidebarHeader
title="Explorer"
onrefresh={handleUpdate}
refreshing={updating}
refreshTitle="Refresh"
>
{#snippet actions()}
<button
class="panel-action-btn"
title="Expand All"
aria-label="Expand All"
onclick={() => explorer.expandAll()}
>
<AppIcon name="expand-all" size={14} />
</button>
<button
class="panel-action-btn"
title="Collapse All"
aria-label="Collapse All"
onclick={() => explorer.collapseAll()}
>
<AppIcon name="collapse-all" size={14} />
</button>
<!--
The same menu the panel's own context menu opens. A right-click on
empty space is not a place to keep the worktree's git operations: it
is invisible, and it is empty space that has to be found first.
-->
<button
class="panel-action-btn"
title="Worktree actions"
aria-label="Worktree actions"
onclick={openPanelMenu}
>
<AppIcon name="ellipsis" size={14} />
</button>
{/snippet}
</SidebarHeader>
<!--
What the tree has no row for: the worktree itself.
-->
<div class="fleet-strip">
<button
class="fleet-root"
title={superproject
? `The superproject on ${superproject.is_detached ? "a detached HEAD" : (superproject.branch ?? "no branch")}`
: "Reading the repositories…"}
onclick={openPanelMenu}
>
<AppIcon name="git-branch" size={11} />
<span class="fleet-root-branch" class:detached={superproject?.is_detached}>
{#if !superproject}
…
{:else if superproject.is_detached}
detached
{:else}
{superproject.branch ?? "—"}
{/if}
</span>
{#if superproject && superproject.files.filter((f) => f.status !== "ignored").length > 0}
{@const changed = superproject.files.filter((f) => f.status !== "ignored").length}
<span class="fleet-count warn" title="{changed} uncommitted change{changed === 1 ? '' : 's'} in the superproject">
{changed}<AppIcon name="file-diff" size={10} />
</span>
{/if}
{#if superproject && superproject.ahead > 0}
<span class="fleet-count" title="the superproject has {superproject.ahead} commit(s) to push">
{superproject.ahead}<AppIcon name="arrow-up" size={10} />
</span>
{/if}
{#if superproject && superproject.behind > 0}
<span class="fleet-count" title="the superproject has {superproject.behind} commit(s) to pull">
{superproject.behind}<AppIcon name="arrow-down" size={10} />
</span>
{/if}
</button>
<span class="fleet-totals">
<span class="fleet-pill" class:warn={dirtyCount > 0} title="{dirtyCount} repositories with uncommitted changes">{dirtyCount} dirty</span>
<span class="fleet-pill" class:warn={aheadCount > 0} title="{aheadCount} repositories with commits to push">
{aheadCount}<AppIcon name="arrow-up" size={10} />
</span>
<span class="fleet-pill" class:warn={behindCount > 0} title="{behindCount} repositories with commits to pull">
{behindCount}<AppIcon name="arrow-down" size={10} />
</span>
{#if absentCount > 0}
<span class="fleet-pill" title="{absentCount} declared repositories are not checked out">{absentCount} absent</span>
{/if}
{#if worktreeConflicts > 0}
<button
class="fleet-pill conflict"
onclick={handleResolveConflicts}
title="{worktreeConflicts} conflicts with other machines — open the machine page to resolve them"
>{worktreeConflicts} conflicts</button>
{/if}
</span>
</div>
{#if structureError}
<div class="panel-error">{structureError}</div>
{/if}
{#if fleetError}
<div class="panel-error">{fleetError}</div>
{/if}
{#if conflicts.error}
<div class="panel-error">{conflicts.error}</div>
{/if}
{#if loadingEntries && visibleRootEntries.length > 0}
<div class="entry-load-bar" style:width="{Math.round((mount.count / visibleRootEntries.length) * 100)}%"></div>
{/if}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="panel-body" oncontextmenu={handlePanelContextMenu}>
{#if rootStatus?.loading && !rootStatus.loaded}
<div class="skel-list">
{#each { length: 10 } as _, i}
<div class="skel-list-row">
<div class="skel" style="width: var(--wb-control-size-12); height: var(--wb-control-size-12);"></div>
<div class="skel" style="width: {60 + ((i * 31) % 40)}%; height: var(--wb-control-size-10);"></div>
</div>
{/each}
</div>
{:else}
<!-- A failed re-read is reported above the tree it failed to refresh.
The rows are the last listing the backend gave; replacing them with
an error would collapse every expansion the user has open. -->
{#if rootStatus?.error}
<div class="panel-error">{rootStatus.error}</div>
{/if}
{#if rootEntries && visibleRootEntries.length === 0}
<div class="panel-state">Worktree is empty.</div>
{:else if rootEntries}
{#each visibleSlice as entry (entry.path)}
<ExplorerNode {entry} />
{/each}
{/if}
{/if}
</div>
<!-- When the repository counts above were read. Quiet, and at the foot, the
same as every other panel that shows something it read once. -->
<div class="panel-standing">
{#if fleetLoading}
Reading the repositories…
{:else if fleetReadAt}
As of {fleetReadAt.toLocaleString()}
{:else}
Not read yet
{/if}
</div>
</div>
{#if contextMenu}
<div
class="context-menu"
use:placeMenu={contextMenu}
>
<button class="context-menu-item" onclick={handleToggleHidden}>
{explorer.showHidden ? "Hide Hidden Files" : "Show Hidden Files"}
</button>
<button class="context-menu-item" onclick={handleRefresh}>
Refresh
</button>
<div class="context-menu-separator"></div>
<button class="context-menu-item" onclick={handleRefactorWorktree}>
Refactor Worktree...
</button>
<button class="context-menu-item" onclick={handleUpdate}>
Worktree Update
</button>
{#if worktreeConflicts > 0}
<button class="context-menu-item context-menu-item--danger" onclick={handleResolveConflicts}>
Resolve {worktreeConflicts} Conflict{worktreeConflicts === 1 ? "" : "s"}...
</button>
{/if}
<div class="context-menu-separator"></div>
<div class="context-menu-label">Superproject</div>
<button class="context-menu-item" onclick={handleRootStage} disabled={git.operationInProgress !== null}>
Stage All
</button>
<button class="context-menu-item" onclick={handleRootCommit} disabled={git.operationInProgress !== null}>
Commit...
</button>
<button class="context-menu-item" onclick={handleRootPush} disabled={git.operationInProgress !== null}>
Push
</button>
<button class="context-menu-item" onclick={handleRootPull} disabled={git.operationInProgress !== null}>
Pull
</button>
<button class="context-menu-item" onclick={handleRootCheckout} disabled={git.operationInProgress !== null}>
Checkout...
</button>
<button class="context-menu-item" onclick={handleRootCreateBranch} disabled={git.operationInProgress !== null}>
Create Branch...
</button>
<div class="context-menu-separator"></div>
<div class="context-menu-label">Every repository</div>
<button class="context-menu-item" onclick={handleFleetStageAll} disabled={git.operationInProgress !== null}>
Fleet Stage All
</button>
<button class="context-menu-item" onclick={handleFleetCommitAll} disabled={git.operationInProgress !== null}>
Fleet Commit All...
</button>
<button class="context-menu-item" onclick={handleFleetPushAll} disabled={git.operationInProgress !== null}>
Fleet Push All
</button>
<button class="context-menu-item" onclick={handleFleetPullAll} disabled={git.operationInProgress !== null}>
Fleet Pull All
</button>
<button class="context-menu-item" onclick={handleFleetCheckoutAll} disabled={git.operationInProgress !== null}>
Fleet Checkout All...
</button>
<button class="context-menu-item" onclick={handleFleetMergeBack} disabled={git.operationInProgress !== null}>
Fleet Merge Back...
</button>
</div>
{/if}
{#if showFleetCommitDialog}
<GitCommitDialog
fleet={true}
onclose={() => (showFleetCommitDialog = false)}
/>
{/if}
{#if showFleetCheckoutDialog}
<GitCheckoutDialog
fleet={true}
onclose={() => (showFleetCheckoutDialog = false)}
/>
{/if}
{#if showMergeBackDialog}
<GitMergeBackDialog
onclose={() => (showMergeBackDialog = false)}
/>
{/if}
{#if showRootCommitDialog}
<GitCommitDialog
repoPath={ROOT_REPO}
onclose={() => { showRootCommitDialog = false; void loadFleet(); }}
/>
{/if}
{#if showRootCheckoutDialog}
<GitCheckoutDialog
repoPath={ROOT_REPO}
onclose={() => { showRootCheckoutDialog = false; void loadFleet(); }}
/>
{/if}
{#if showRootBranchDialog}
<GitBranchCreateDialog
repoPath={ROOT_REPO}
onclose={() => { showRootBranchDialog = false; void loadFleet(); }}
/>
{/if}
<style>
.panel {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.entry-load-bar {
height: var(--wb-control-size-2);
background: var(--vscode-progressBar-background, #0e70c0);
flex-shrink: 0;
transition: width 0.1s linear;
}
.fleet-strip {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--wb-spacing-8);
padding: var(--wb-spacing-4) var(--wb-spacing-12) var(--wb-spacing-5);
flex-shrink: 0;
border-bottom: 1px solid var(--vscode-panel-border);
font-size: var(--wb-font-size-10);
color: var(--vscode-disabledForeground);
}
.fleet-root {
display: inline-flex;
align-items: center;
gap: var(--wb-spacing-5);
min-width: 0;
border: none;
background: none;
color: inherit;
font-size: inherit;
cursor: pointer;
}
.fleet-root:hover {
color: var(--vscode-foreground);
}
.fleet-root-branch {
max-width: var(--wb-control-size-110);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.fleet-root-branch.detached {
color: var(--vscode-errorForeground, #f14c4c);
}
.fleet-count {
font-variant-numeric: tabular-nums;
display: inline-flex;
align-items: center;
gap: 2px;
}
.fleet-count.warn {
color: var(--vscode-editorWarning-foreground, #cca700);
}
.fleet-totals {
display: inline-flex;
align-items: center;
gap: var(--wb-spacing-5);
flex-shrink: 0;
}
.fleet-pill {
padding: var(--wb-spacing-1) var(--wb-spacing-6);
border: none;
border-radius: 999px;
background: color-mix(in srgb, currentColor 10%, transparent);
color: inherit;
font-size: inherit;
font-variant-numeric: tabular-nums;
display: inline-flex;
align-items: center;
gap: 2px;
}
.fleet-pill.warn {
color: var(--vscode-editorWarning-foreground, #cca700);
background: color-mix(in srgb, var(--vscode-editorWarning-foreground, #cca700) 12%, transparent);
}
.fleet-pill.conflict {
color: var(--vscode-errorForeground, #f14c4c);
background: color-mix(in srgb, var(--vscode-errorForeground, #f14c4c) 14%, transparent);
font-weight: 700;
cursor: pointer;
}
.panel-body {
flex: 1;
min-height: 0;
overflow: auto;
/* Room below the last entry to right-click. Without it the panel's own
empty space is only whatever the tree does not fill — a full tree left
nowhere to click, so the menu that acts on the WORKTREE could not be
opened at all once the worktree had enough files in it. */
padding: var(--wb-spacing-6) 0 var(--wb-spacing-120);
}
.panel-state,
.panel-error {
padding: var(--wb-spacing-12) var(--wb-spacing-14);
font-size: var(--wb-font-size-12);
}
.panel-state {
color: var(--vscode-disabledForeground);
}
.panel-error {
color: var(--vscode-errorForeground);
}
.skel-list {
display: flex;
flex-direction: column;
gap: var(--wb-spacing-6);
padding: var(--wb-spacing-8) var(--wb-spacing-14);
}
.skel-list-row {
display: flex;
align-items: center;
gap: var(--wb-spacing-8);
}
</style>