fix: v2.6.1 — panes start in their folder again after an update (#69), Changes panel stacks every repo, clickable paths in chat
- #69: tmux applies a pane's `-c` only while its server's own cwd exists. The server was forked from the dashboard's cwd (the plugin cache dir), outlives updates, and the old cache dir is pruned — after that every new pane started in a deleted directory, where `claude` died at once ("run not found") and a shell printed getcwd errors. tmux calls now run from `/`, and the pane's sh wrapper cds into its folder itself, which also heals a tmux server that is already stuck. - Changes panel: a workspace's extra folders are sections stacked under the workspace folder (VS Code Source Control style), with Close repository / Close other repositories on a header's right-click. - Chat: URLs and absolute paths are links; Claude Code's `[Image: source:]` line folds into its turn; uploads under `<data dir>/drops/` are listed. - Update check: a checkout holding commits a newer release lacks reports `diverged_from_release` instead of offering a fast-forward that can only fail.
This commit is contained in:
+187
-98
@@ -55,7 +55,15 @@ import { runWorktreeSession, type RunWorktree } from "../lib/runWorktree";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useLocation, useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { PanelLeftClose, PanelRightClose, Maximize2, Minimize2, ChartColumn } from "lucide-react";
|
||||
import {
|
||||
PanelLeftClose,
|
||||
PanelRightClose,
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
ChartColumn,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
} from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
import type {
|
||||
CwdSuggestion,
|
||||
@@ -66,6 +74,7 @@ import type {
|
||||
} from "../lib/api";
|
||||
import type { Lane, LaneFeature, ProofFeature, Session } from "../lib/types";
|
||||
import { toast } from "../lib/toast";
|
||||
import { ContextMenu, MenuItem, useContextMenu } from "../components/ContextMenu";
|
||||
import { confirmDialog, confirmDiscard } from "../lib/confirm";
|
||||
import { keepIfSame } from "../lib/keepIfSame";
|
||||
import { useWorkLanes } from "../hooks/useLanes";
|
||||
@@ -709,9 +718,27 @@ export function Workspace() {
|
||||
? sessionParam
|
||||
: undefined;
|
||||
|
||||
// The workspace's extra folders: each is its own repo the Changes panel can
|
||||
// show instead (`?source=`), picked in its header. Read per workspace, and
|
||||
// again from the home's Folder list whenever it adds or removes one.
|
||||
// An unsaved whole-file edit in a Changes panel section, by section key. While
|
||||
// one is open the panel stays on the tree it was opened in — moving it
|
||||
// (another workspace, session or worktree) would remount it and drop the
|
||||
// edit — until the user says to discard it, or saves.
|
||||
const [dirtyBy, setDirtyBy] = useState<Record<string, boolean>>({});
|
||||
const changesDirty = Object.values(dirtyBy).some(Boolean);
|
||||
const dirtyFor = useCallback(
|
||||
(key: string) => (dirty: boolean) => setDirtyBy((d) => ({ ...d, [key]: dirty })),
|
||||
[]
|
||||
);
|
||||
const wantPanel =
|
||||
selectedLaneId === null ? null : { laneId: selectedLaneId, session: gitSession };
|
||||
const keyOf = (p: typeof wantPanel) => (p ? `${p.laneId}:${p.session ?? ""}` : "");
|
||||
const wantPanelKey = keyOf(wantPanel);
|
||||
const [panelAt, setPanelAt] = useState(wantPanel);
|
||||
const panelKey = keyOf(panelAt);
|
||||
|
||||
// The workspace's extra folders: each is its own repo, stacked under the
|
||||
// workspace folder's in the Changes panel (`?source=`). Read for the
|
||||
// workspace the panel shows, and again from the home's Folder list whenever
|
||||
// it adds or removes one.
|
||||
const [gitFolders, setGitFolders] = useState<{ laneId: number; items: LaneSource[] } | null>(
|
||||
null
|
||||
);
|
||||
@@ -723,35 +750,37 @@ export function Workspace() {
|
||||
}),
|
||||
[]
|
||||
);
|
||||
const panelLaneId = panelAt?.laneId ?? null;
|
||||
useEffect(() => {
|
||||
if (selectedLaneId === null) return;
|
||||
api.lanes.sources(selectedLaneId).then(
|
||||
(v) => takeGitFolders(selectedLaneId, v),
|
||||
() => setGitFolders({ laneId: selectedLaneId, items: [] })
|
||||
if (panelLaneId === null) return;
|
||||
api.lanes.sources(panelLaneId).then(
|
||||
(v) => takeGitFolders(panelLaneId, v),
|
||||
() => setGitFolders({ laneId: panelLaneId, items: [] })
|
||||
);
|
||||
}, [selectedLaneId, takeGitFolders]);
|
||||
const folders = gitFolders?.laneId === selectedLaneId ? gitFolders.items : [];
|
||||
const [gitSourcePick, setGitSourcePick] = useState<{ laneId: number; id: number } | null>(null);
|
||||
// A pick holds for its own workspace, while that folder is still listed.
|
||||
const gitSource =
|
||||
gitSourcePick?.laneId === selectedLaneId && folders.some((f) => f.id === gitSourcePick.id)
|
||||
? gitSourcePick.id
|
||||
: undefined;
|
||||
|
||||
// An unsaved whole-file edit in the Changes panel. While it is open the
|
||||
// panel stays on the tree it was opened in — moving it (another workspace,
|
||||
// session, worktree or folder) would remount it and drop the edit — until
|
||||
// the user says to discard it, or saves.
|
||||
const [changesDirty, setChangesDirty] = useState(false);
|
||||
const wantPanel =
|
||||
selectedLaneId === null
|
||||
? null
|
||||
: { laneId: selectedLaneId, session: gitSession, source: gitSource };
|
||||
const keyOf = (p: typeof wantPanel) =>
|
||||
p ? `${p.laneId}:${p.session ?? ""}:${p.source ?? ""}` : "";
|
||||
const wantPanelKey = keyOf(wantPanel);
|
||||
const [panelAt, setPanelAt] = useState(wantPanel);
|
||||
const panelKey = keyOf(panelAt);
|
||||
}, [panelLaneId, takeGitFolders]);
|
||||
const folders = gitFolders?.laneId === panelLaneId ? gitFolders.items : [];
|
||||
// Sections folded shut ("" = the workspace folder, else the folder's id).
|
||||
const [foldedSources, setFoldedSources] = useState<Set<string>>(new Set());
|
||||
// Right-click on a section: close extra folders — which REMOVES them from the
|
||||
// workspace (the folder stays on disk). The workspace folder never closes.
|
||||
const sourceCtx = useContextMenu<string>();
|
||||
// Bumped after a close here, so the home's Folder list re-reads.
|
||||
const [sourcesVersion, setSourcesVersion] = useState(0);
|
||||
const closeSources = async (laneId: number, keys: string[]) => {
|
||||
if (keys.some((k) => dirtyBy[k]) && !(await confirmDiscard())) return;
|
||||
try {
|
||||
let view: LaneSourcesView | null = null;
|
||||
for (const k of keys) view = await api.lanes.removeSource(laneId, Number(k));
|
||||
if (view) takeGitFolders(laneId, view);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
api.lanes.sources(laneId).then(
|
||||
(v) => takeGitFolders(laneId, v),
|
||||
() => undefined
|
||||
);
|
||||
}
|
||||
setSourcesVersion((n) => n + 1);
|
||||
};
|
||||
const asking = useRef(false);
|
||||
useEffect(() => {
|
||||
if (wantPanelKey === panelKey) return;
|
||||
@@ -764,7 +793,7 @@ export function Workspace() {
|
||||
void confirmDiscard().then((ok) => {
|
||||
asking.current = false;
|
||||
if (!ok) return;
|
||||
setChangesDirty(false);
|
||||
setDirtyBy({});
|
||||
setPanelAt(wantPanel);
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- keyed on the keys
|
||||
@@ -780,8 +809,12 @@ export function Workspace() {
|
||||
const onOpen = (e: Event) => {
|
||||
const { laneId, path } = (e as CustomEvent<OpenInChanges>).detail;
|
||||
if (laneId !== panelLaneRef.current) return;
|
||||
// Its path is the workspace folder's: back from an extra folder first.
|
||||
setGitSourcePick(null);
|
||||
// Its path is the workspace folder's: unfold that section.
|
||||
setFoldedSources((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete("");
|
||||
return next;
|
||||
});
|
||||
setChangesOpen(true);
|
||||
setChangesRequest((r) => ({ path, nonce: (r?.nonce ?? 0) + 1 }));
|
||||
};
|
||||
@@ -792,10 +825,7 @@ export function Workspace() {
|
||||
useEffect(() => {
|
||||
if (!changesOpen) setChangesRequest(null);
|
||||
}, [changesOpen]);
|
||||
// Not on a folder pick: the request above makes one, to land in the panel
|
||||
// it remounts; the picker drops the request itself.
|
||||
const panelTreeKey = panelAt ? `${panelAt.laneId}:${panelAt.session ?? ""}` : "";
|
||||
useEffect(() => setChangesRequest(null), [panelTreeKey]);
|
||||
useEffect(() => setChangesRequest(null), [panelKey]);
|
||||
/** Runs `fn` once an unsaved edit (if any) may be dropped. */
|
||||
const afterDrop = (fn: () => void) => {
|
||||
if (!changesDirty) return fn();
|
||||
@@ -1036,6 +1066,7 @@ export function Workspace() {
|
||||
lane={currentLane}
|
||||
onRenamed={() => void refreshLanes()}
|
||||
onSourcesChange={(v) => takeGitFolders(currentLane.id, v)}
|
||||
sourcesVersion={sourcesVersion}
|
||||
onMenuAction={(action) => {
|
||||
if (action === "new-here") {
|
||||
setAddLanePrefill({ sourceRepo: currentLane.source_repo || currentLane.cwd });
|
||||
@@ -1268,34 +1299,6 @@ export function Workspace() {
|
||||
<span className="px-1 text-xs font-medium text-fg-secondary">
|
||||
{tLanes("panel.changes")}
|
||||
</span>
|
||||
{/* Which repo the panel shows: the workspace's folder or
|
||||
one of its extra folders. Only when it has any. */}
|
||||
{folders.length > 0 && (
|
||||
<select
|
||||
data-testid="panel-git-folder"
|
||||
value={gitSource ?? ""}
|
||||
onChange={(e) => {
|
||||
setChangesRequest(null);
|
||||
setGitSourcePick(
|
||||
e.target.value
|
||||
? { laneId: currentLane.id, id: Number(e.target.value) }
|
||||
: null
|
||||
);
|
||||
}}
|
||||
aria-label={tLanes("panel.folder")}
|
||||
title={tLanes("panel.folder")}
|
||||
className="min-w-0 max-w-[12rem] truncate rounded-md border border-border bg-surface-2 px-1.5 py-0.5 text-xs text-fg-primary"
|
||||
>
|
||||
<option value="">
|
||||
{currentLane.cwd.split(/[\\/]/).filter(Boolean).pop() || currentLane.cwd}
|
||||
</option>
|
||||
{folders.map((f) => (
|
||||
<option key={f.id} value={f.id}>
|
||||
{f.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
{/* The opened session's analysis page — a page of its own. */}
|
||||
<button
|
||||
type="button"
|
||||
@@ -1350,30 +1353,114 @@ export function Workspace() {
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex min-h-0 flex-1 flex-col px-3 pt-2">
|
||||
{panelAt && (
|
||||
<LaneChangesPanel
|
||||
// A new tree is a new panel: no read for the previous one
|
||||
// can land in it. `panelAt` holds still while an edit is
|
||||
// unsaved (see above).
|
||||
key={panelKey}
|
||||
laneId={panelAt.laneId}
|
||||
session={panelAt.session}
|
||||
source={panelAt.source}
|
||||
full={changesFull}
|
||||
wide={changesRatio(changesSize) >= 0.45}
|
||||
onDirtyChange={setChangesDirty}
|
||||
openRequest={panelAt.source ? null : changesRequest}
|
||||
// A sibling worktree forks from the lane's SOURCE repo when
|
||||
// it has one (this lane is itself a worktree of it); an
|
||||
// adopted lane is its own repo.
|
||||
onNewLane={(branch) => {
|
||||
setAddLanePrefill({
|
||||
sourceRepo: currentLane.source_repo || currentLane.cwd,
|
||||
branch,
|
||||
});
|
||||
setAddLaneOpen(true);
|
||||
}}
|
||||
/>
|
||||
{panelAt &&
|
||||
[
|
||||
{ key: "", source: undefined as number | undefined },
|
||||
...folders.map((f) => ({ key: String(f.id), source: f.id })),
|
||||
].map(({ key, source }, i) => {
|
||||
const panel = (
|
||||
<LaneChangesPanel
|
||||
// A new tree is a new panel: no read for the previous
|
||||
// one can land in it. `panelAt` holds still while an
|
||||
// edit is unsaved (see above).
|
||||
key={`${panelKey}:${key}`}
|
||||
laneId={panelAt.laneId}
|
||||
session={panelAt.session}
|
||||
source={source}
|
||||
full={changesFull}
|
||||
wide={changesRatio(changesSize) >= 0.45}
|
||||
onDirtyChange={dirtyFor(key)}
|
||||
openRequest={source ? null : changesRequest}
|
||||
// A sibling worktree forks from the lane's SOURCE repo
|
||||
// when it has one (this lane is itself a worktree of
|
||||
// it); an adopted lane is its own repo.
|
||||
onNewLane={(branch) => {
|
||||
setAddLanePrefill({
|
||||
sourceRepo: currentLane.source_repo || currentLane.cwd,
|
||||
branch,
|
||||
});
|
||||
setAddLaneOpen(true);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
// No extra folders: the one panel, as it always was.
|
||||
if (!folders.length) return panel;
|
||||
// Else every repo is a section, stacked like VS Code's
|
||||
// Source Control; the open ones share the height. A
|
||||
// folded one stays mounted (CSS), so an unsaved edit in
|
||||
// it survives.
|
||||
const folded = foldedSources.has(key);
|
||||
const name = source
|
||||
? folders[i - 1]!.name
|
||||
: currentLane.cwd.split(/[\\/]/).filter(Boolean).pop() || currentLane.cwd;
|
||||
return (
|
||||
<section
|
||||
key={key}
|
||||
data-testid="panel-git-source"
|
||||
className={`flex flex-col border-b border-border-light ${
|
||||
folded ? "shrink-0" : "min-h-0 flex-1"
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={!folded}
|
||||
onContextMenu={(e) => sourceCtx.open(e, key)}
|
||||
onClick={() =>
|
||||
setFoldedSources((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (!next.delete(key)) next.add(key);
|
||||
return next;
|
||||
})
|
||||
}
|
||||
className="flex shrink-0 items-center gap-1 py-1 text-left text-xs font-medium text-fg-secondary hover:text-fg-primary"
|
||||
>
|
||||
{folded ? (
|
||||
<ChevronRight className="h-3.5 w-3.5 shrink-0" />
|
||||
) : (
|
||||
<ChevronDown className="h-3.5 w-3.5 shrink-0" />
|
||||
)}
|
||||
<span className="truncate">{name}</span>
|
||||
</button>
|
||||
<div className={folded ? "hidden" : "flex min-h-0 flex-1 flex-col"}>
|
||||
{panel}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
{panelAt && sourceCtx.menu && (
|
||||
<ContextMenu
|
||||
x={sourceCtx.menu.x}
|
||||
y={sourceCtx.menu.y}
|
||||
onClose={sourceCtx.close}
|
||||
testId="panel-git-source-menu"
|
||||
>
|
||||
{sourceCtx.menu.item !== "" && (
|
||||
<MenuItem
|
||||
testId="panel-git-source-close"
|
||||
onSelect={() => {
|
||||
const key = sourceCtx.menu!.item;
|
||||
sourceCtx.close();
|
||||
void closeSources(panelAt.laneId, [key]);
|
||||
}}
|
||||
>
|
||||
{tLanes("changes.closeRepo")}
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuItem
|
||||
testId="panel-git-source-close-others"
|
||||
disabled={!folders.some((f) => String(f.id) !== sourceCtx.menu!.item)}
|
||||
onSelect={() => {
|
||||
const key = sourceCtx.menu!.item;
|
||||
sourceCtx.close();
|
||||
void closeSources(
|
||||
panelAt.laneId,
|
||||
folders.map((f) => String(f.id)).filter((k) => k !== key)
|
||||
);
|
||||
}}
|
||||
>
|
||||
{tLanes("changes.closeOtherRepos")}
|
||||
</MenuItem>
|
||||
</ContextMenu>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
@@ -1397,14 +1484,16 @@ export function Workspace() {
|
||||
{tLanes("changes.title")}
|
||||
</span>
|
||||
</button>
|
||||
{panelAt && (
|
||||
<LaneGitRailCounts
|
||||
laneId={panelAt.laneId}
|
||||
session={panelAt.session}
|
||||
source={panelAt.source}
|
||||
onShowAll={() => setChangesOpen(true)}
|
||||
/>
|
||||
)}
|
||||
{panelAt &&
|
||||
[undefined, ...folders.map((f) => f.id)].map((source) => (
|
||||
<LaneGitRailCounts
|
||||
key={source ?? ""}
|
||||
laneId={panelAt.laneId}
|
||||
session={panelAt.session}
|
||||
source={source}
|
||||
onShowAll={() => setChangesOpen(true)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user