diff --git a/app/src/renderer/hooks/useAgentConfig.ts b/app/src/renderer/hooks/useAgentConfig.ts new file mode 100644 index 0000000..8787ad0 --- /dev/null +++ b/app/src/renderer/hooks/useAgentConfig.ts @@ -0,0 +1,24 @@ +import { useEffect, useState } from "react"; +import { api } from "../api.js"; +import { reportError } from "../error-reporting.js"; +import type { AgentConfig } from "@sproutgit/types"; + +/** Loads the configured coding agent (Settings → AI Agent) once on mount. */ +export function useAgentConfig() { + const [agentConfig, setAgentConfig] = useState(null); + + useEffect(() => { + // A failure here leaves the Chat tab looking unconfigured with no + // indication why — surface it instead. + void api + .getAgentConfig() + .then(setAgentConfig) + .catch((err: unknown) => + reportError("Failed to load agent configuration", err), + ); + }, []); + + const agentConfigured = !!agentConfig?.command.trim(); + + return { agentConfig, agentConfigured }; +} diff --git a/app/src/renderer/hooks/useAutoUpdateListeners.ts b/app/src/renderer/hooks/useAutoUpdateListeners.ts new file mode 100644 index 0000000..ee2b5e2 --- /dev/null +++ b/app/src/renderer/hooks/useAutoUpdateListeners.ts @@ -0,0 +1,41 @@ +import { useEffect } from "react"; +import { api } from "../api.js"; +import { reportError } from "../error-reporting.js"; +import { useUpdateStore } from "../stores/update-store.js"; + +/** Wires electron-updater's IPC events into the update store. */ +export function useAutoUpdateListeners() { + const { updateState, setUpdateState } = useUpdateStore(); + + useEffect(() => { + const offChecking = api.onUpdateChecking(() => + setUpdateState({ status: "checking" }), + ); + const offAvailable = api.onUpdateAvailable((version: string) => + setUpdateState({ status: "available", version }), + ); + const offNotAvailable = api.onUpdateNotAvailable(() => + setUpdateState({ status: "up-to-date" }), + ); + const offDownloading = api.onUpdateDownloading((progress: number) => + setUpdateState({ status: "downloading", progress }), + ); + const offReady = api.onUpdateReady(() => + setUpdateState({ status: "ready" }), + ); + const offError = api.onUpdateError((message: string) => { + reportError("Update failed", message); + setUpdateState({ status: "idle" }); + }); + return () => { + offChecking(); + offAvailable(); + offNotAvailable(); + offDownloading(); + offReady(); + offError(); + }; + }, [setUpdateState]); + + return updateState; +} diff --git a/app/src/renderer/hooks/useAvailableShells.ts b/app/src/renderer/hooks/useAvailableShells.ts new file mode 100644 index 0000000..1379bde --- /dev/null +++ b/app/src/renderer/hooks/useAvailableShells.ts @@ -0,0 +1,19 @@ +import { useEffect, useState } from "react"; +import { api } from "../api.js"; + +/** Lists the shells available for the terminal's "choose shell" picker. */ +export function useAvailableShells() { + const [availableShells, setAvailableShells] = useState< + { name: string; path: string }[] + >([]); + + useEffect(() => { + // Intentionally silent: worst case the shell picker just shows no options. + void api + .listShells() + .then(setAvailableShells) + .catch(() => undefined); + }, []); + + return availableShells; +} diff --git a/app/src/renderer/hooks/useCommitDiffState.ts b/app/src/renderer/hooks/useCommitDiffState.ts new file mode 100644 index 0000000..9fd9cd0 --- /dev/null +++ b/app/src/renderer/hooks/useCommitDiffState.ts @@ -0,0 +1,104 @@ +import { useState } from "react"; +import { api } from "../api.js"; +import type { CommitEntry, DiffFileEntry } from "@sproutgit/types"; +import type { ToastFn } from "../toast-context.js"; + +/** Commit/range diff selection state for the Graph tab's diff panel. */ +export function useCommitDiffState(params: { + gitRepoPath: string; + toast: ToastFn; +}) { + const { gitRepoPath, toast } = params; + + const [selectedCommits, setSelectedCommits] = useState([]); + const [commitDiffRange, setCommitDiffRange] = useState(null); + const [commitDiffFiles, setCommitDiffFiles] = useState([]); + const [commitDiffContent, setCommitDiffContent] = useState(""); + const [commitDiffFile, setCommitDiffFile] = useState( + null, + ); + const [commitDiffLoading, setCommitDiffLoading] = useState(false); + const [commitDiffFileLoading, setCommitDiffFileLoading] = useState(false); + + const selectedCommit = selectedCommits[0] ?? null; + + function clearCommitDiff() { + setSelectedCommits([]); + setCommitDiffFiles([]); + setCommitDiffContent(""); + setCommitDiffFile(null); + } + + async function loadCommitDiff(commit: CommitEntry) { + setSelectedCommits([commit]); + setCommitDiffFile(null); + setCommitDiffContent(""); + setCommitDiffLoading(true); + try { + const range = + commit.parents.length > 0 + ? `${commit.parents[0]}..${commit.hash}` + : commit.hash; + setCommitDiffRange(range); + const files = await api.getDiffFiles(gitRepoPath, range); + setCommitDiffFiles(files as DiffFileEntry[]); + } catch (err) { + toast(`Failed to load commit diff: ${String(err)}`, "error"); + setCommitDiffFiles([]); + } finally { + setCommitDiffLoading(false); + } + } + + async function loadCommitRangeDiff(from: CommitEntry, to: CommitEntry) { + setSelectedCommits([from, to]); + setCommitDiffFile(null); + setCommitDiffContent(""); + setCommitDiffLoading(true); + try { + const range = `${from.hash}..${to.hash}`; + setCommitDiffRange(range); + const files = await api.getDiffFiles(gitRepoPath, range); + setCommitDiffFiles(files as DiffFileEntry[]); + } catch (err) { + toast(`Failed to load range diff: ${String(err)}`, "error"); + setCommitDiffFiles([]); + } finally { + setCommitDiffLoading(false); + } + } + + async function loadCommitDiffFile(file: DiffFileEntry) { + if (!selectedCommit) return; + setCommitDiffFile(file); + setCommitDiffFileLoading(true); + try { + const range = + commitDiffRange ?? + (selectedCommit.parents.length > 0 + ? `${selectedCommit.parents[0]}..${selectedCommit.hash}` + : selectedCommit.hash); + const content = await api.getDiffContent(gitRepoPath, range, file.path); + setCommitDiffContent(content as string); + } catch (err) { + toast(`Failed to load diff: ${String(err)}`, "error"); + setCommitDiffContent(""); + } finally { + setCommitDiffFileLoading(false); + } + } + + return { + selectedCommits, + selectedCommit, + commitDiffFiles, + commitDiffContent, + commitDiffFile, + commitDiffLoading, + commitDiffFileLoading, + loadCommitDiff, + loadCommitRangeDiff, + loadCommitDiffFile, + clearCommitDiff, + }; +} diff --git a/app/src/renderer/hooks/useEditorTabsForWorktree.ts b/app/src/renderer/hooks/useEditorTabsForWorktree.ts new file mode 100644 index 0000000..0546487 --- /dev/null +++ b/app/src/renderer/hooks/useEditorTabsForWorktree.ts @@ -0,0 +1,25 @@ +import { useEditorStore, tabKey } from "../stores/editor-store.js"; + +/** Derives the open editor tabs (and active tab) scoped to one worktree. */ +export function useEditorTabsForWorktree(worktreePath: string | undefined) { + const editorTabs = useEditorStore((s) => s.tabs); + const editorTabOrder = useEditorStore((s) => s.tabOrder); + const editorActiveTabKeyRaw = useEditorStore((s) => s.activeTabKey); + + const editorTabsForActiveWorktree = editorTabOrder + .map((k) => editorTabs[k]) + .filter( + (t): t is NonNullable => !!t && t.worktreePath === worktreePath, + ); + const editorActiveTabKey = editorTabsForActiveWorktree.some( + (t) => tabKey(t.worktreePath, t.relativePath) === editorActiveTabKeyRaw, + ) + ? editorActiveTabKeyRaw + : null; + const activeEditorTab = + editorTabsForActiveWorktree.find( + (t) => tabKey(t.worktreePath, t.relativePath) === editorActiveTabKey, + ) ?? null; + + return { editorTabsForActiveWorktree, editorActiveTabKey, activeEditorTab }; +} diff --git a/app/src/renderer/hooks/useFileEditorActions.ts b/app/src/renderer/hooks/useFileEditorActions.ts new file mode 100644 index 0000000..b67243c --- /dev/null +++ b/app/src/renderer/hooks/useFileEditorActions.ts @@ -0,0 +1,67 @@ +import { api } from "../api.js"; +import { + useEditorStore, + openOrFocusTab, + setTabLoaded, + setTabError, + setTabSaved, + resolveConflictReload, +} from "../stores/editor-store.js"; +import { useWorkspaceStore } from "../stores/workspace-store.js"; +import type { ToastFn } from "../toast-context.js"; +import type { WorktreeInfo } from "@sproutgit/types"; + +/** File editor tab actions: open/read, save, and reload-from-disk (conflict resolution). */ +export function useFileEditorActions(params: { + activeWorktree: WorktreeInfo | null; + toast: ToastFn; +}) { + const { activeWorktree, toast } = params; + + async function openFile(relativePath: string) { + if (!activeWorktree) return; + const worktreePath = activeWorktree.path; + const key = openOrFocusTab(worktreePath, relativePath); + useWorkspaceStore.setState({ activeTab: "files" }); + const tab = useEditorStore.getState().tabs[key]; + if (!tab) return; + // "already loaded" means a load previously succeeded, not that the + // content happens to be non-empty — an actually-empty file would + // otherwise get re-read every time the tab is reopened/focused. + if (!tab.loading && !tab.error) return; + try { + const result = await api.readFile(worktreePath, relativePath); + setTabLoaded(key, result.content, result.mtimeMs); + } catch (err) { + setTabError(key, `Failed to read file: ${String(err)}`); + } + } + + async function saveFile(key: string) { + const tab = useEditorStore.getState().tabs[key]; + if (!tab || !tab.dirty) return; + try { + const result = await api.writeFile( + tab.worktreePath, + tab.relativePath, + tab.content, + ); + setTabSaved(key, tab.content, result.mtimeMs); + } catch (err) { + toast(`Failed to save ${tab.relativePath}: ${String(err)}`, "error"); + } + } + + async function reloadFileFromDisk(key: string) { + const tab = useEditorStore.getState().tabs[key]; + if (!tab) return; + try { + const result = await api.readFile(tab.worktreePath, tab.relativePath); + resolveConflictReload(key, result.content, result.mtimeMs); + } catch (err) { + toast(`Failed to reload ${tab.relativePath}: ${String(err)}`, "error"); + } + } + + return { openFile, saveFile, reloadFileFromDisk }; +} diff --git a/app/src/renderer/hooks/useRecentWorkspaces.ts b/app/src/renderer/hooks/useRecentWorkspaces.ts new file mode 100644 index 0000000..7cf2066 --- /dev/null +++ b/app/src/renderer/hooks/useRecentWorkspaces.ts @@ -0,0 +1,42 @@ +import { useEffect, useState } from "react"; +import { useNavigate } from "@tanstack/react-router"; +import { api } from "../api.js"; +import type { RecentWorkspace } from "@sproutgit/types"; + +/** Recent-workspace list for the title bar's workspace switcher, plus switching itself. */ +export function useRecentWorkspaces(workspacePath: string) { + const navigate = useNavigate(); + const [recentWorkspaces, setRecentWorkspaces] = useState( + [], + ); + + async function loadRecentWorkspaces(): Promise { + try { + const ws = await api.listRecentWorkspaces(); + const sorted = [...ws].sort( + (a, b) => + new Date(b.lastOpenedAt).getTime() - + new Date(a.lastOpenedAt).getTime(), + ); + setRecentWorkspaces(sorted); + return sorted; + } catch { + return recentWorkspaces; + } + } + + useEffect(() => { + void loadRecentWorkspaces(); + // loadRecentWorkspaces is redefined every render (it closes over + // recentWorkspaces for its error fallback) — only re-run on navigation. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [workspacePath]); + + function switchWorkspace(path: string) { + if (path === workspacePath) return; + void api.addRecentWorkspace(path); + void navigate({ to: "/workspace", search: { path } }); + } + + return { recentWorkspaces, loadRecentWorkspaces, switchWorkspace }; +} diff --git a/app/src/renderer/hooks/useRemoteOps.ts b/app/src/renderer/hooks/useRemoteOps.ts new file mode 100644 index 0000000..d164de4 --- /dev/null +++ b/app/src/renderer/hooks/useRemoteOps.ts @@ -0,0 +1,78 @@ +import { + useFetch, + usePull, + usePush, + describeFetchSummary, +} from "../queries.js"; +import { useWorkspaceStore } from "../stores/workspace-store.js"; +import type { ToastFn } from "../toast-context.js"; +import type { WorktreePushStatus } from "@sproutgit/types"; + +/** Fetch/pull/push actions for the active worktree, mirroring their in-flight flags into the workspace store. */ +export function useRemoteOps(params: { + activeWorktreePath: string | undefined; + gitRepoPath: string; + pushStatus: WorktreePushStatus | undefined; + toast: ToastFn; + onPushNeedsPublish: () => void; +}) { + const { + activeWorktreePath, + gitRepoPath, + pushStatus, + toast, + onPushNeedsPublish, + } = params; + + const fetchMutation = useFetch(activeWorktreePath ?? "", gitRepoPath); + const pullMutation = usePull(activeWorktreePath ?? "", gitRepoPath); + const pushMutation = usePush(activeWorktreePath ?? ""); + + async function doFetch() { + if (!activeWorktreePath) return; + useWorkspaceStore.setState({ fetching: true }); + try { + const summary = await fetchMutation.mutateAsync(); + toast( + describeFetchSummary(summary), + summary.hadNoRemotes ? "info" : "success", + ); + } catch (err) { + toast(`Fetch failed: ${String(err)}`, "error"); + } finally { + useWorkspaceStore.setState({ fetching: false }); + } + } + + async function doPull() { + if (!activeWorktreePath) return; + useWorkspaceStore.setState({ pulling: true }); + try { + await pullMutation.mutateAsync(); + toast("Pulled", "success"); + } catch (err) { + toast(`Pull failed: ${String(err)}`, "error"); + } finally { + useWorkspaceStore.setState({ pulling: false }); + } + } + + async function doPush() { + if (!activeWorktreePath) return; + if (!pushStatus?.upstream) { + onPushNeedsPublish(); + return; + } + useWorkspaceStore.setState({ pushing: true }); + try { + await pushMutation.mutateAsync(); + toast("Pushed", "success"); + } catch (err) { + toast(`Push failed: ${String(err)}`, "error"); + } finally { + useWorkspaceStore.setState({ pushing: false }); + } + } + + return { doFetch, doPull, doPush }; +} diff --git a/app/src/renderer/hooks/useScaffoldKickoff.ts b/app/src/renderer/hooks/useScaffoldKickoff.ts new file mode 100644 index 0000000..c4b0d1e --- /dev/null +++ b/app/src/renderer/hooks/useScaffoldKickoff.ts @@ -0,0 +1,60 @@ +import { useEffect, useState } from "react"; +import { api } from "../api.js"; +import { consumePendingScaffold } from "../pending-scaffold.js"; +import { useWorkspaceStore } from "../stores/workspace-store.js"; +import type { AgentConfig, WorktreeInfo } from "@sproutgit/types"; +import type { ToastFn } from "../toast-context.js"; + +/** + * Scaffold kickoff for a project just created from the homescreen's "New + * from idea" flow — see pending-scaffold.ts. Keyed by workspacePath (not the + * worktree path — git reports that back through its own realpath resolution, + * e.g. macOS's /var → /private/var, which would never match the + * locally-constructed path the dialog stored it under). Consumed at most once + * per workspace: harmless to re-run this effect (e.g. on a worktrees + * refetch), since consumePendingScaffold() returns undefined thereafter. + */ +export function useScaffoldKickoff(params: { + activeWorktree: WorktreeInfo | null; + agentConfig: AgentConfig | null; + workspacePath: string; + toast: ToastFn; +}) { + const { activeWorktree, agentConfig, workspacePath, toast } = params; + const [chatAutoPrompt, setChatAutoPrompt] = useState( + undefined, + ); + + useEffect(() => { + if (!activeWorktree || !agentConfig) return; + const prompt = consumePendingScaffold(workspacePath); + if (!prompt) return; + if (agentConfig.mode === "integrated") { + setChatAutoPrompt(prompt); + useWorkspaceStore.setState({ activeTab: "chat" }); + return; + } + void (async () => { + try { + const terminalId = await api.launchAgent({ + workspacePath, + worktreePath: activeWorktree.path, + }); + // Give the CLI a moment to boot before "typing" the kickoff prompt. + setTimeout(() => { + void api + .writeTerminal(terminalId, `${prompt}\n`) + .catch(() => undefined); + }, 1500); + } catch (err) { + toast( + `Failed to launch agent for scaffolding: ${String(err)}`, + "error", + ); + } + })(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [activeWorktree, agentConfig, workspacePath]); + + return chatAutoPrompt; +} diff --git a/app/src/renderer/hooks/useTerminalManager.tsx b/app/src/renderer/hooks/useTerminalManager.tsx new file mode 100644 index 0000000..7b6c112 --- /dev/null +++ b/app/src/renderer/hooks/useTerminalManager.tsx @@ -0,0 +1,513 @@ +import { + useEffect, + useRef, + useState, + type CSSProperties, + type MouseEvent as ReactMouseEvent, +} from "react"; +import { + Pencil, + Plus, + PanelTop, + SquareSplitHorizontal, + LayoutGrid, + X, + Trash2, + ChevronsRight, +} from "lucide-react"; +import { api } from "../api.js"; +import { useContextMenu } from "@sproutgit/ui"; +import { useWorkspaceStore } from "../stores/workspace-store.js"; +import type { ToastFn } from "../toast-context.js"; + +/** Max characters of PTY output retained per terminal (~1MB of UTF-16). Older + * output is dropped from the front rather than kept forever — a terminal + * running a chatty long-lived process must not accumulate output for its + * entire lifetime. */ +const TERMINAL_BUFFER_CAP = 512 * 1024; + +function shellDisplayName(shellPath: string | null | undefined): string { + if (!shellPath) return "terminal"; + const base = shellPath.split(/[/\\]/).pop() ?? shellPath; + return base.replace(/\.exe$/i, ""); +} + +/** + * Owns terminal session lifecycle for the workspace view: the non-reactive + * PTY output buffers, all terminal-related IPC listeners (data/exit, hook + * launch, agent launch), and the actions the terminal tab UI needs (open, + * close, rename, layout, context menu). + */ +export function useTerminalManager(params: { + workspacePath: string; + activeWorktreePath: string | undefined; + defaultShell: string; + toast: ToastFn; +}) { + const { workspacePath, activeWorktreePath, defaultShell, toast } = params; + const contextMenu = useContextMenu(); + + const terminalSessions = useWorkspaceStore((s) => s.terminalSessions); + const activeTerminalId = useWorkspaceStore((s) => s.activeTerminalId); + const terminalLayout = useWorkspaceStore((s) => s.terminalLayout); + + // Sessions for the currently selected worktree. All other sessions keep + // their PTYs running in the background and reappear when you switch back. + const visibleSessions = terminalSessions.filter( + (s) => s.cwd === activeWorktreePath, + ); + + const [renamingTerminalId, setRenamingTerminalId] = useState( + null, + ); + const [renameValue, setRenameValue] = useState(""); + const renameInputRef = useRef(null); + + // Non-reactive terminal data buffer. Capped per terminal so a long-running + // or repeatedly-opened terminal doesn't accumulate unbounded output for + // the lifetime of the session — see TERMINAL_BUFFER_CAP above. + const terminalDataRef = useRef>(new Map()); + // Cumulative characters trimmed from the front of each terminal's buffer, + // so TerminalPane (which reads pendingData as an ever-growing stream) can + // still compute correct write deltas after a trim. + const terminalDroppedLenRef = useRef>(new Map()); + const terminalBuffersHydratedRef = useRef(false); + // The store's terminalSessions survive WorkspaceInner unmounting/remounting + // (e.g. navigating to Projects and back) so background PTYs keep their + // pendingData/droppedLen, but these refs do not — they'd otherwise reset to + // empty and the next onTerminalData event would clobber the preserved + // buffer with just the newest chunk. Reseed once per mount, synchronously + // during render so it happens before the onTerminalData listener attaches. + if (!terminalBuffersHydratedRef.current) { + terminalBuffersHydratedRef.current = true; + for (const sess of useWorkspaceStore.getState().terminalSessions) { + terminalDataRef.current.set(sess.id, sess.pendingData); + terminalDroppedLenRef.current.set(sess.id, sess.droppedLen); + } + } + + useEffect(() => { + // If the active terminal was closed (not in any session), fall back to + // the last visible session for the current worktree. + if ( + activeTerminalId && + !terminalSessions.some((s) => s.id === activeTerminalId) + ) { + const activePath = useWorkspaceStore.getState().activeWorktree?.path; + const last = + terminalSessions.filter((s) => s.cwd === activePath).at(-1)?.id ?? null; + useWorkspaceStore.setState({ activeTerminalId: last }); + } + if ( + renamingTerminalId && + !terminalSessions.some((s) => s.id === renamingTerminalId) + ) { + setRenamingTerminalId(null); + setRenameValue(""); + } + }, [activeTerminalId, renamingTerminalId, terminalSessions]); + + useEffect(() => { + if (!renamingTerminalId || !renameInputRef.current) return; + renameInputRef.current.focus(); + renameInputRef.current.select(); + }, [renamingTerminalId]); + + // ── Terminal IPC ────────────────────────────────────────────────────── + + useEffect(() => { + const offData = api.onTerminalData((id: string, data: string) => { + const prevBuffer = terminalDataRef.current.get(id) ?? ""; + const prevDropped = terminalDroppedLenRef.current.get(id) ?? 0; + let buffer: string; + let droppedLen: number; + if (data.length >= TERMINAL_BUFFER_CAP) { + // A single chunk alone meets/exceeds the cap — skip concatenating it + // with the old buffer so we never allocate a temporary string larger + // than the cap (the old buffer is entirely superseded anyway). + const overflow = data.length - TERMINAL_BUFFER_CAP; + buffer = data.slice(overflow); + droppedLen = prevDropped + prevBuffer.length + overflow; + } else { + const combined = prevBuffer + data; + if (combined.length > TERMINAL_BUFFER_CAP) { + const overflow = combined.length - TERMINAL_BUFFER_CAP; + buffer = combined.slice(overflow); + droppedLen = prevDropped + overflow; + } else { + buffer = combined; + droppedLen = prevDropped; + } + } + terminalDataRef.current.set(id, buffer); + terminalDroppedLenRef.current.set(id, droppedLen); + useWorkspaceStore.setState((s) => ({ + terminalSessions: s.terminalSessions.map((sess) => + sess.id === id ? { ...sess, pendingData: buffer, droppedLen } : sess, + ), + })); + }); + + const offExit = api.onTerminalExit((id: string) => { + terminalDataRef.current.delete(id); + terminalDroppedLenRef.current.delete(id); + useWorkspaceStore.setState((s) => { + const remaining = s.terminalSessions.filter((sess) => sess.id !== id); + const currentPath = s.activeWorktree?.path; + const visibleRemaining = remaining.filter( + (sess) => sess.cwd === currentPath, + ); + return { + terminalSessions: remaining, + activeTerminalId: + s.activeTerminalId === id + ? (visibleRemaining.at(-1)?.id ?? null) + : s.activeTerminalId, + }; + }); + }); + + return () => { + offData(); + offExit(); + }; + }, []); + + // ── Hook terminal launch listener ───────────────────────────────────── + + useEffect(() => { + const offHookTerminal = api.onHookTerminalLaunch((event) => { + const label = `hook: ${event.hookName}`; + useWorkspaceStore.setState((s) => { + const cwd = event.cwd; + return { + terminalSessions: [ + ...s.terminalSessions, + { + id: event.terminalId, + cwd, + label: makeTerminalLabel( + s.terminalSessions.filter((sess) => sess.cwd === cwd), + label, + ), + pendingData: "", + droppedLen: 0, + agentId: null, + }, + ], + activeTerminalId: event.terminalId, + activeTab: "terminal", + worktreeActiveTerminalId: { + ...s.worktreeActiveTerminalId, + [cwd]: event.terminalId, + }, + }; + }); + }); + + return () => { + offHookTerminal(); + }; + }, []); + + // ── Agent terminal launch listener ───────────────────────────────────── + + useEffect(() => { + const offAgentTerminal = api.onAgentTerminalLaunch((event) => { + useWorkspaceStore.setState((s) => { + const cwd = event.cwd; + return { + terminalSessions: [ + ...s.terminalSessions, + { + id: event.terminalId, + cwd, + label: makeTerminalLabel( + s.terminalSessions.filter((sess) => sess.cwd === cwd), + "AI Agent", + ), + pendingData: "", + droppedLen: 0, + agentId: "agent", + }, + ], + activeTerminalId: event.terminalId, + activeTab: "terminal", + worktreeActiveTerminalId: { + ...s.worktreeActiveTerminalId, + [cwd]: event.terminalId, + }, + }; + }); + }); + + return () => { + offAgentTerminal(); + }; + }, []); + + // ── Actions ─────────────────────────────────────────────────────────── + + function makeTerminalLabel( + sessions: typeof terminalSessions, + baseLabel: string, + ) { + const trimmed = baseLabel.trim() || "terminal"; + const existing = sessions.filter((s) => s.label === trimmed).length; + return existing === 0 ? trimmed : `${trimmed} (${existing + 1})`; + } + + async function openTerminal( + cwd: string, + label?: string, + shellOverride?: string, + ) { + try { + const terminalArgs: { + cwd: string; + label?: string; + shell?: string; + } = { cwd }; + if (label) terminalArgs.label = label; + const resolvedShell = shellOverride ?? defaultShell; + if (resolvedShell) terminalArgs.shell = resolvedShell; + const id = await api.createTerminal(terminalArgs); + const shellLabel = shellDisplayName(resolvedShell); + useWorkspaceStore.setState((s) => ({ + terminalSessions: [ + ...s.terminalSessions, + { + id, + cwd, + label: makeTerminalLabel( + s.terminalSessions.filter((sess) => sess.cwd === cwd), + label ?? shellLabel, + ), + pendingData: "", + droppedLen: 0, + agentId: null, + }, + ], + activeTerminalId: id, + activeTab: "terminal", + worktreeActiveTerminalId: { ...s.worktreeActiveTerminalId, [cwd]: id }, + })); + } catch (err) { + toast(`Failed to open terminal: ${String(err)}`, "error"); + } + } + + async function launchAgent(worktreePath: string) { + try { + await api.launchAgent({ workspacePath, worktreePath }); + } catch (err) { + toast(`Failed to launch agent: ${String(err)}`, "error"); + } + } + + function terminalPanelStyle(id: string): CSSProperties { + if (terminalLayout === "tabs") { + return { + display: id === activeTerminalId ? "block" : "none", + minHeight: 0, + flex: 1, + }; + } + return { + display: "block", + minHeight: 0, + minWidth: 0, + flex: 1, + }; + } + + function terminalWrapperClass() { + if (terminalLayout === "split") return "flex flex-1 min-h-0"; + if (terminalLayout === "grid") + return "grid flex-1 min-h-0 grid-cols-2 auto-rows-fr"; + return "flex flex-1 min-h-0 flex-col"; + } + + async function closeTerminal(id: string) { + await api.closeTerminal(id).catch(() => undefined); + terminalDataRef.current.delete(id); + terminalDroppedLenRef.current.delete(id); + useWorkspaceStore.setState((s) => { + const remaining = s.terminalSessions.filter((sess) => sess.id !== id); + const currentPath = s.activeWorktree?.path; + const visibleRemaining = remaining.filter( + (sess) => sess.cwd === currentPath, + ); + return { + terminalSessions: remaining, + activeTerminalId: + s.activeTerminalId === id + ? (visibleRemaining.at(-1)?.id ?? null) + : s.activeTerminalId, + }; + }); + if (renamingTerminalId === id) { + setRenamingTerminalId(null); + setRenameValue(""); + } + } + + async function closeTerminals(ids: string[]) { + if (ids.length === 0) return; + await Promise.all( + ids.map((id) => api.closeTerminal(id).catch(() => undefined)), + ); + for (const id of ids) { + terminalDataRef.current.delete(id); + terminalDroppedLenRef.current.delete(id); + } + useWorkspaceStore.setState((s) => { + const idSet = new Set(ids); + const remaining = s.terminalSessions.filter( + (sess) => !idSet.has(sess.id), + ); + const currentPath = s.activeWorktree?.path; + const visibleRemaining = remaining.filter( + (sess) => sess.cwd === currentPath, + ); + return { + terminalSessions: remaining, + activeTerminalId: idSet.has(s.activeTerminalId ?? "") + ? (visibleRemaining.at(-1)?.id ?? null) + : s.activeTerminalId, + }; + }); + if (renamingTerminalId && ids.includes(renamingTerminalId)) { + setRenamingTerminalId(null); + setRenameValue(""); + } + } + + function startTerminalRename(id: string) { + const session = terminalSessions.find((s) => s.id === id); + if (!session) return; + useWorkspaceStore.setState({ activeTerminalId: id, activeTab: "terminal" }); + setRenamingTerminalId(id); + setRenameValue(session.label); + } + + function commitTerminalRename() { + if (!renamingTerminalId) return; + const trimmed = renameValue.trim(); + if (trimmed) { + useWorkspaceStore.setState((s) => ({ + terminalSessions: s.terminalSessions.map((sess) => + sess.id === renamingTerminalId ? { ...sess, label: trimmed } : sess, + ), + })); + } + setRenamingTerminalId(null); + setRenameValue(""); + } + + function cancelTerminalRename() { + setRenamingTerminalId(null); + setRenameValue(""); + } + + function openTerminalTabMenu(e: ReactMouseEvent, sessionId: string) { + const index = visibleSessions.findIndex((s) => s.id === sessionId); + if (index === -1) return; + const idsToRight = visibleSessions.slice(index + 1).map((s) => s.id); + const otherIds = visibleSessions + .filter((s) => s.id !== sessionId) + .map((s) => s.id); + contextMenu.open(e, [ + { + label: "Rename", + icon: , + onClick: () => startTerminalRename(sessionId), + }, + { + label: "New Terminal", + icon: , + onClick: () => { + void openTerminal(activeWorktreePath ?? workspacePath); + }, + }, + "separator", + { + label: "Tabbed Layout", + icon: , + onClick: () => + useWorkspaceStore.setState({ + terminalLayout: "tabs", + activeTerminalId: sessionId, + activeTab: "terminal", + }), + }, + { + label: "Split Layout", + icon: , + onClick: () => + useWorkspaceStore.setState({ + terminalLayout: "split", + activeTerminalId: sessionId, + activeTab: "terminal", + }), + }, + { + label: "Grid Layout", + icon: , + onClick: () => + useWorkspaceStore.setState({ + terminalLayout: "grid", + activeTerminalId: sessionId, + activeTab: "terminal", + }), + }, + "separator", + { + label: "Close", + icon: , + danger: true, + onClick: () => { + void closeTerminal(sessionId); + }, + }, + { + label: "Close Others", + icon: , + disabled: otherIds.length === 0, + danger: true, + onClick: () => { + void closeTerminals(otherIds); + }, + }, + { + label: "Close To Right", + icon: , + disabled: idsToRight.length === 0, + danger: true, + onClick: () => { + void closeTerminals(idsToRight); + }, + }, + ]); + } + + return { + visibleSessions, + activeTerminalId, + terminalLayout, + renamingTerminalId, + renameValue, + setRenameValue, + renameInputRef, + openTerminal, + closeTerminal, + closeTerminals, + launchAgent, + startTerminalRename, + commitTerminalRename, + cancelTerminalRename, + terminalPanelStyle, + terminalWrapperClass, + openTerminalTabMenu, + }; +} diff --git a/app/src/renderer/hooks/useWorkspaceFileWatchers.ts b/app/src/renderer/hooks/useWorkspaceFileWatchers.ts new file mode 100644 index 0000000..63f5e05 --- /dev/null +++ b/app/src/renderer/hooks/useWorkspaceFileWatchers.ts @@ -0,0 +1,183 @@ +import { useEffect, useRef } from "react"; +import type { useQueryClient } from "@tanstack/react-query"; +import { api } from "../api.js"; +import { reportError } from "../error-reporting.js"; +import { resetWorkspaceStore } from "../stores/workspace-store.js"; +import { + useEditorStore, + handleExternalChange, + tabKey, +} from "../stores/editor-store.js"; +import { qk } from "../queries.js"; +import type { ToastFn } from "../toast-context.js"; +import type { FileChangedEvent, WorktreeInfo } from "@sproutgit/types"; + +/** + * All of the workspace's background subscriptions that keep server state in + * sync with the filesystem/git and don't belong to a more specific concern + * (terminals, worktree selection, commit diff): the workspace-level git + * watcher, the active worktree's file-content watcher, refetch-on-focus, + * worktree metadata pruning, MCP auto-start, and workspace-switch cleanup. + */ +export function useWorkspaceFileWatchers(params: { + workspacePath: string; + gitRepoPath: string; + activeWorktreePath: string | undefined; + worktrees: WorktreeInfo[]; + qc: ReturnType; + toast: ToastFn; +}) { + const { + workspacePath, + gitRepoPath, + activeWorktreePath, + worktrees, + qc, + toast, + } = params; + + // ── Reset UI state when workspace path changes ──────────────────────── + + useEffect(() => { + resetWorkspaceStore(workspacePath); + }, [workspacePath]); + + // ── MCP server auto-start ──────────────────────────────────────────── + // No-op unless the user previously enabled MCP for this workspace in + // Settings — this just makes "enabled" survive across app restarts. + // Gated on gitRepoPath for the same reason as the file watcher below: + // checking/persisting MCP state touches .sproutgit/state.db, which would + // otherwise get created (via openWorkspaceDb's side effect) inside a + // not-yet-migrated plain repo and make its working tree look dirty right + // as the legacy-layout bare-repo migration runs. + // Intentionally no stop-on-unmount: like background terminal sessions, + // the server should keep running while the app is open even after + // navigating away from this workspace view; it's torn down on workspace + // close (WORKSPACE_CLOSE) and app quit instead. + useEffect(() => { + if (!gitRepoPath) return; + // A failure here means the MCP server just doesn't start, invisibly — + // surface it instead. + void api + .mcpEnsureStarted(workspacePath) + .catch((err: unknown) => reportError("Failed to start MCP server", err)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [gitRepoPath]); + + // ── Close terminals when switching to a DIFFERENT workspace ────────── + // We use a ref so the cleanup only fires when the path genuinely changes + // (not when the component unmounts on navigation to the Projects screen). + const prevWorkspacePathRef = useRef(workspacePath); + useEffect(() => { + const prevPath = prevWorkspacePathRef.current; + prevWorkspacePathRef.current = workspacePath; + if (prevPath && prevPath !== workspacePath) { + void api.closeTerminalsForPath(prevPath); + } + }, [workspacePath]); + + // ── File watcher → invalidate queries ──────────────────────────────── + // Wait for gitRepoPath (root, always bare) to be known before watching — + // starting earlier would watch the pre-migration path, and on Windows + // `fs.watch` holds an open handle that blocks the rename that converts a + // workspace to the bare-root layout (EPERM). + + useEffect(() => { + if (!gitRepoPath) return; + // A failure here means the app silently stops picking up external + // changes (branch switches, commits, worktree adds from another tool) + // for the whole session — surface it rather than letting the workspace + // look "stuck"/stale with no explanation. + void api + .startWatching(gitRepoPath) + .catch((err: unknown) => + toast(`Failed to watch workspace for changes: ${String(err)}`, "error"), + ); + const offWorktree = api.onWorktreeChanged(() => { + void qc.invalidateQueries({ queryKey: qk.worktrees(gitRepoPath) }); + }); + const offRefs = api.onGitRefsChanged(() => { + void qc.invalidateQueries({ queryKey: qk.commits(gitRepoPath) }); + void qc.invalidateQueries({ queryKey: qk.refs(gitRepoPath) }); + }); + return () => { + void api.stopWatching(gitRepoPath).catch(() => undefined); + offWorktree(); + offRefs(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [gitRepoPath]); + + // ── File content watcher (active worktree) → file tree + open tab live-sync ── + // Distinct from the workspace-level watcher above (which only tracks .git/* + // for branch/ref changes) — this one covers the working tree's file + // *contents* so the file browser refreshes and open editor tabs pick up + // external edits (e.g. an AI agent or another tool writing to a file). + // + // It also doubles as a fast path for the Changes tab's status query: any + // content change likely means `git status` changed too, so invalidate it + // immediately instead of waiting on its 15s poll (kept as a fallback for + // changes this watcher can't see, e.g. an index/stash operation from + // another tool with no working-tree file write). + useEffect(() => { + const worktreePath = activeWorktreePath; + if (!worktreePath) return; + + void api.startFileWatching(worktreePath); + const offFileChanged = api.onFileChanged((event: FileChangedEvent) => { + if (event.worktreePath !== worktreePath) return; + void qc.invalidateQueries({ queryKey: qk.fileTree(worktreePath) }); + void qc.invalidateQueries({ queryKey: qk.worktreeStatus(worktreePath) }); + if (gitRepoPath) + void qc.invalidateQueries({ + queryKey: qk.worktreeChangeCounts(gitRepoPath), + }); + + const key = tabKey(worktreePath, event.relativePath); + if (!useEditorStore.getState().tabs[key]) return; + + if (event.type === "deleted") return; // leave the buffer as-is; save will recreate the file + + api + .readFile(worktreePath, event.relativePath) + .then((result) => { + // Re-read the tab's current state rather than the one captured + // before this async read started — it may have been saved, + // reloaded, or closed while the read was in flight. + const currentTab = useEditorStore.getState().tabs[key]; + if (!currentTab) return; + if (result.mtimeMs <= currentTab.knownMtimeMs) return; + handleExternalChange(key, result.content, result.mtimeMs); + }) + .catch(() => undefined); + }); + + return () => { + void api.stopFileWatching(worktreePath); + offFileChanged(); + }; + }, [activeWorktreePath, gitRepoPath, qc]); + + // ── Refresh worktrees when the window regains focus ─────────────────── + // Catches worktrees an external tool (e.g. Claude Code) registered while + // this window was unfocused, on top of the filesystem watcher above. + useEffect(() => { + if (!gitRepoPath) return; + const onFocus = () => + void qc.invalidateQueries({ queryKey: qk.worktrees(gitRepoPath) }); + window.addEventListener("focus", onFocus); + return () => window.removeEventListener("focus", onFocus); + }, [gitRepoPath, qc]); + + // ── Drop worktree_metadata rows for worktrees that have disappeared ──── + // Best-effort bookkeeping cleanup only — never runs `git worktree prune`. + useEffect(() => { + if (!workspacePath || worktrees.length === 0) return; + void api + .pruneWorktreeMetadata({ + workspacePath, + activeWorktreePaths: worktrees.map((w) => w.path), + }) + .catch(() => undefined); + }, [workspacePath, worktrees]); +} diff --git a/app/src/renderer/hooks/useWorkspaceUiPersistence.ts b/app/src/renderer/hooks/useWorkspaceUiPersistence.ts new file mode 100644 index 0000000..bedcfc2 --- /dev/null +++ b/app/src/renderer/hooks/useWorkspaceUiPersistence.ts @@ -0,0 +1,108 @@ +import { useEffect } from "react"; +import { api } from "../api.js"; +import { + useWorkspaceStore, + type Tab, + type TerminalLayout, +} from "../stores/workspace-store.js"; +import type { AgentConfig, WorktreeInfo } from "@sproutgit/types"; + +/** + * Keeps the workspace UI's transient session state (active worktree/tab, + * terminal layout, sidebar collapse) mirrored into sessionStorage so it + * survives a reload, auto-switches the active tab away from one that just + * became unavailable, loads the default shell preference, and wires the + * Cmd/Ctrl+B sidebar-toggle shortcut. + */ +export function useWorkspaceUiPersistence(params: { + workspacePath: string; + activeWorktree: WorktreeInfo | null; + activeTab: Tab; + terminalLayout: TerminalLayout; + agentConfig: AgentConfig | null; + sidebarCollapsed: boolean; + setSidebarCollapsed: (updater: (collapsed: boolean) => boolean) => void; +}) { + const { + workspacePath, + activeWorktree, + activeTab, + terminalLayout, + agentConfig, + sidebarCollapsed, + setSidebarCollapsed, + } = params; + + // ── Default shell preference ────────────────────────────────────────── + useEffect(() => { + void api + .getSetting("default_shell") + .then((v: string | null) => + useWorkspaceStore.setState({ defaultShell: v ?? "" }), + ) + .catch(() => undefined); + }, [workspacePath]); + + // ── Session persistence ─────────────────────────────────────────────── + + useEffect(() => { + if (activeWorktree) + sessionStorage.setItem("sg_active_wt", activeWorktree.path); + }, [activeWorktree]); + + // ── Auto-switch tab if activeTab becomes disabled ───────────────────── + useEffect(() => { + if ( + !activeWorktree && + (activeTab === "staging" || + activeTab === "terminal" || + activeTab === "chat" || + activeTab === "files") + ) { + useWorkspaceStore.setState({ activeTab: "graph" }); + } + if ( + activeTab === "chat" && + agentConfig && + agentConfig.mode !== "integrated" + ) { + useWorkspaceStore.setState({ activeTab: "graph" }); + } + }, [activeWorktree, activeTab, agentConfig]); + + useEffect(() => { + sessionStorage.setItem("sg_active_tab", activeTab); + }, [activeTab]); + + useEffect(() => { + sessionStorage.setItem("sg_terminal_layout", terminalLayout); + }, [terminalLayout]); + + useEffect(() => { + sessionStorage.setItem( + "sg_sidebar_collapsed", + sidebarCollapsed ? "1" : "0", + ); + }, [sidebarCollapsed]); + + // ── Cmd/Ctrl+B toggles the worktree sidebar ("work mode") ────────────── + useEffect(() => { + function onKey(e: KeyboardEvent) { + if (!(e.metaKey || e.ctrlKey) || e.key.toLowerCase() !== "b") return; + // Don't hijack Cmd/Ctrl+B while the user is typing (e.g. it's "bold" in + // a rich-text/contentEditable field, or a meaningful character in a + // terminal/input) or if another handler already consumed the event. + if (e.defaultPrevented) return; + const target = e.target; + if (target instanceof HTMLElement) { + const tag = target.tagName; + if (tag === "INPUT" || tag === "TEXTAREA" || target.isContentEditable) + return; + } + e.preventDefault(); + setSidebarCollapsed((v) => !v); + } + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [setSidebarCollapsed]); +} diff --git a/app/src/renderer/hooks/useWorktreeSelection.ts b/app/src/renderer/hooks/useWorktreeSelection.ts new file mode 100644 index 0000000..80727c8 --- /dev/null +++ b/app/src/renderer/hooks/useWorktreeSelection.ts @@ -0,0 +1,279 @@ +import { useEffect, useRef, useState } from "react"; +import { api } from "../api.js"; +import { useWorkspaceStore } from "../stores/workspace-store.js"; +import type { useDeleteWorktree } from "../queries.js"; +import type { ToastFn } from "../toast-context.js"; +import type { + WorkspaceStatus, + WorktreeInfo, + WorktreeSwitchHookSource, +} from "@sproutgit/types"; + +async function runSwitchAndTriggerHooks(args: { + workspacePath: string; + targetWorktreePath: string; + initiatingWorktreePath: string | null; + source: WorktreeSwitchHookSource; +}): Promise { + await api.runSwitchHooks(args); + await api.runTriggerHooks({ + workspacePath: args.workspacePath, + trigger: "after_worktree_switch", + worktreePath: args.targetWorktreePath, + initiatingWorktreePath: args.initiatingWorktreePath, + source: args.source, + }); +} + +/** + * Owns picking the initial/active worktree (including restoring the last + * selection from the DB and auto-switching to a newly created worktree), + * plus the switch/delete/create-hooks actions that mutate it. + */ +export function useWorktreeSelection(params: { + workspacePath: string; + worktrees: WorktreeInfo[]; + rootPath: string | undefined; + gitRepoPath: string; + workspaceStatus: WorkspaceStatus | undefined; + activeWorktree: WorktreeInfo | null; + deleteWorktreeMutation: ReturnType; + toast: ToastFn; + closeDeleteDialog: () => void; +}) { + const { + workspacePath, + worktrees, + rootPath: rootP, + gitRepoPath, + workspaceStatus, + activeWorktree, + deleteWorktreeMutation, + toast, + closeDeleteDialog, + } = params; + + const [pendingNewWorktreePath, setPendingNewWorktreePath] = useState< + string | null + >(null); + const lastWorktreeWorkspaceRef = useRef(""); + + useEffect(() => { + // Filter out root worktree — it should never be active + const selectableWorktrees = worktrees.filter((w) => w.path !== rootP); + if (selectableWorktrees.length === 0) { + useWorkspaceStore.setState({ activeWorktree: null }); + return; + } + + const workspaceChanged = lastWorktreeWorkspaceRef.current !== workspacePath; + lastWorktreeWorkspaceRef.current = workspacePath; + + // If the workspace hasn't changed, preserve an already-valid selection + // (e.g. a new worktree was added/removed — don't reset the active one). + if (!workspaceChanged) { + // If a new worktree was just created, switch to it automatically. + if (pendingNewWorktreePath) { + const newWt = selectableWorktrees.find( + (w) => w.path === pendingNewWorktreePath, + ); + if (newWt) { + const prevPath = + useWorkspaceStore.getState().activeWorktree?.path ?? null; + setPendingNewWorktreePath(null); + useWorkspaceStore.setState((s) => ({ + activeWorktree: newWt, + creatingWorktree: false, + pendingCreationBranch: null, + worktreeActiveTerminalId: { + ...s.worktreeActiveTerminalId, + ...(prevPath ? { [prevPath]: s.activeTerminalId } : {}), + }, + activeTerminalId: s.worktreeActiveTerminalId[newWt.path] ?? null, + })); + void runSwitchAndTriggerHooks({ + workspacePath, + targetWorktreePath: newWt.path, + initiatingWorktreePath: prevPath, + source: "create", + }).catch((err: unknown) => + toast(`Switch hooks failed: ${String(err)}`, "error"), + ); + return; + } + } + const current = useWorkspaceStore.getState().activeWorktree; + if (current && selectableWorktrees.some((w) => w.path === current.path)) + return; + } + + // On workspace open: restore the last-selected worktree from the DB, fall + // back to first non-detached, then first overall. + void api + .getWorkspaceState(workspacePath, "activeWorktreePath") + .then((saved) => { + const restored = saved + ? (selectableWorktrees.find((w) => w.path === saved) ?? null) + : null; + const initial = + restored ?? + selectableWorktrees.find((w) => !w.detached) ?? + selectableWorktrees[0] ?? + null; + useWorkspaceStore.setState({ activeWorktree: initial }); + if (initial) { + void runSwitchAndTriggerHooks({ + workspacePath, + targetWorktreePath: initial.path, + initiatingWorktreePath: null, + source: "load", + }).catch((err: unknown) => + toast(`Switch hooks failed: ${String(err)}`, "error"), + ); + } + }) + .catch(() => { + const initial = + selectableWorktrees.find((w) => !w.detached) ?? + selectableWorktrees[0] ?? + null; + useWorkspaceStore.setState({ activeWorktree: initial }); + if (initial) { + void runSwitchAndTriggerHooks({ + workspacePath, + targetWorktreePath: initial.path, + initiatingWorktreePath: null, + source: "load", + }).catch((err: unknown) => + toast(`Switch hooks failed: ${String(err)}`, "error"), + ); + } + }); + // toast is recreated every render (see toast-context.tsx) — omit it so + // this effect only reruns when the worktree selection actually changes. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [worktrees, rootP, workspacePath, pendingNewWorktreePath]); + + async function handleWorktreeSwitch(wt: WorktreeInfo) { + if (activeWorktree?.path === wt.path) return; + const prevPath = activeWorktree?.path ?? null; + // Save the active terminal for the outgoing worktree and restore the + // last known active terminal for the incoming worktree. + useWorkspaceStore.setState((s) => { + const savedForTarget = s.worktreeActiveTerminalId[wt.path] ?? null; + const visibleForTarget = s.terminalSessions.filter( + (sess) => sess.cwd === wt.path, + ); + const restoredId = + savedForTarget && + visibleForTarget.some((sess) => sess.id === savedForTarget) + ? savedForTarget + : (visibleForTarget.at(-1)?.id ?? null); + return { + activeWorktree: wt, + activeTerminalId: restoredId, + worktreeActiveTerminalId: { + ...s.worktreeActiveTerminalId, + ...(prevPath ? { [prevPath]: s.activeTerminalId } : {}), + }, + }; + }); + void api + .setWorkspaceState(workspacePath, "activeWorktreePath", wt.path) + .catch(() => undefined); + void runSwitchAndTriggerHooks({ + workspacePath, + targetWorktreePath: wt.path, + initiatingWorktreePath: prevPath, + source: "manual", + }).catch((err: unknown) => + toast(`Switch hooks failed: ${String(err)}`, "error"), + ); + } + + async function doDeleteWorktree(wt: WorktreeInfo) { + const isDeletingActive = activeWorktree?.path === wt.path; + const nextWt = isDeletingActive + ? (worktrees.find( + (w) => w.path !== wt.path && w.path !== workspaceStatus?.rootPath, + ) ?? null) + : null; + + try { + if (isDeletingActive && nextWt) { + try { + await api.runSwitchHooks({ + workspacePath, + targetWorktreePath: nextWt.path, + initiatingWorktreePath: wt.path, + source: "delete", + }); + await api.runTriggerHooks({ + workspacePath, + trigger: "after_worktree_switch", + worktreePath: nextWt.path, + initiatingWorktreePath: wt.path, + source: "delete", + }); + } catch { + /* non-critical */ + } + } + + // before/after_worktree_remove hooks and terminal cleanup now run + // server-side as part of the worktree:delete IPC call itself (see + // app/src/main/worktree-lifecycle.ts). afterRemoveWorktreePath is the + // UI's own "next active worktree" concept (no MCP equivalent), passed + // through explicitly so the after-hook's env vars still reflect it. + const afterRemoveWorktreePath = (nextWt ?? activeWorktree)?.path ?? null; + + // Switch the active worktree away *before* the mutation so that no git + // queries fire on the deleted path while or after the deletion runs. + if (isDeletingActive) + useWorkspaceStore.setState({ activeWorktree: nextWt }); + await deleteWorktreeMutation.mutateAsync({ + workspacePath, + rootRepoPath: gitRepoPath, + ...(workspaceStatus?.worktreesPath + ? { managedWorktreesPath: workspaceStatus.worktreesPath } + : {}), + worktreePath: wt.path, + // Never delete the branch of an external worktree — an external tool + // owns it. The main process re-enforces this guard server-side too. + deleteBranch: !wt.isExternal && !!wt.branch, + branchName: wt.branch ?? null, + initiatingWorktreePath: activeWorktree?.path ?? null, + afterRemoveWorktreePath, + }); + + toast("Worktree removed", "success"); + } catch (err) { + toast(`Failed to remove worktree: ${String(err)}`, "error"); + } finally { + closeDeleteDialog(); + } + } + + // after_worktree_create hooks never fire retroactively for a worktree we + // adopted rather than created — this lets the user opt in explicitly + // (e.g. to run a dependency install) from the worktree's context menu. + async function runCreateHooksFor(wt: WorktreeInfo) { + try { + await api.runCreateHooks({ + workspacePath, + newWorktreePath: wt.path, + initiatingWorktreePath: activeWorktree?.path ?? null, + }); + toast("Create hooks ran", "success"); + } catch (err) { + toast(`Create hooks failed: ${String(err)}`, "error"); + } + } + + return { + setPendingNewWorktreePath, + handleWorktreeSwitch, + doDeleteWorktree, + runCreateHooksFor, + }; +} diff --git a/app/src/renderer/routes/workspace.tsx b/app/src/renderer/routes/workspace.tsx index d8efab8..ae77357 100644 --- a/app/src/renderer/routes/workspace.tsx +++ b/app/src/renderer/routes/workspace.tsx @@ -1,52 +1,43 @@ -import { api } from '../api.js'; -import { createRoute, useNavigate, useSearch } from '@tanstack/react-router'; -import { rootRoute } from './__root.js'; -import { useState, useEffect, useRef } from 'react'; -import { useQueryClient } from '@tanstack/react-query'; +import { api } from "../api.js"; +import { createRoute, useSearch } from "@tanstack/react-router"; +import { rootRoute } from "./__root.js"; +import { useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { StagingPanel, Spinner, ContextMenuProvider } from "@sproutgit/ui"; +import { GitBranch, GitMerge, Terminal, Bot, FileCode2 } from "lucide-react"; +import type { WorktreeInfo, TerminalInfo } from "@sproutgit/types"; +import { useToast } from "../toast-context.js"; +import { useWorkspaceStore } from "../stores/workspace-store.js"; import { - CommitGraph, - StagingPanel, - TerminalPane, - Spinner, - ContextMenuProvider, - useContextMenu, - WorkspaceHooksModal, - WindowControls, - UpdateBadge, -} from '@sproutgit/ui'; -import { GitBranch, Terminal, GitMerge, X, ChevronRight, ChevronDown, Settings, Plus, Columns2, Rows3, LayoutGrid, Pencil, PanelTop, SquareSplitHorizontal, ChevronsRight, Trash2, Bot, FileCode2, AppWindow, FolderOpen, Radar } from 'lucide-react'; -import type { CommitEntry, DiffFileEntry, WorktreeInfo, WorktreeSwitchHookSource, AgentConfig, FileChangedEvent, RecentWorkspace, TerminalInfo } from '@sproutgit/types'; -import { useToast } from '../toast-context.js'; -import { reportError } from '../error-reporting.js'; -import { useUpdateStore } from '../stores/update-store.js'; -import { useWorkspaceStore, resetWorkspaceStore } from '../stores/workspace-store.js'; -import { - useEditorStore, tabKey, - openOrFocusTab, - setTabLoaded, - setTabError, + setActiveTab as setActiveEditorTab, + closeTab as closeEditorTab, setTabContent, - setTabSaved, - handleExternalChange, - resolveConflictReload, resolveConflictKeepMine, - closeTab as closeEditorTab, - setActiveTab as setActiveEditorTab, -} from '../stores/editor-store.js'; -import { WorktreeSidebar } from '../workspace/WorktreeSidebar.js'; -import { AgentSessionsPanel } from '../workspace/AgentSessionsPanel.js'; -import { ChatPanel } from '../workspace/ChatPanel.js'; -import { CommitDiffPanel } from '../workspace/CommitDiffPanel.js'; -import { FileTreePanel } from '../workspace/FileTreePanel.js'; -import { FileEditorPanel } from '../workspace/FileEditorPanel.js'; -import { NewWorktreeDialog } from '../workspace/dialogs/NewWorktreeDialog.js'; -import { DeleteWorktreeDialog } from '../workspace/dialogs/DeleteWorktreeDialog.js'; -import { PublishDialog } from '../workspace/dialogs/PublishDialog.js'; -import { RunHookDialog } from '../workspace/dialogs/RunHookDialog.js'; -import { CreatePrDialog } from '../workspace/dialogs/CreatePrDialog.js'; -import { loadCommitMessageGeneratorSettings } from '../commit-message-generator-settings.js'; -import { consumePendingScaffold } from '../pending-scaffold.js'; +} from "../stores/editor-store.js"; +import { WorktreeSidebar } from "../workspace/WorktreeSidebar.js"; +import { AgentSessionsPanel } from "../workspace/AgentSessionsPanel.js"; +import { ChatPanel } from "../workspace/ChatPanel.js"; +import { FileTreePanel } from "../workspace/FileTreePanel.js"; +import { FileEditorPanel } from "../workspace/FileEditorPanel.js"; +import { WorkspaceHeader } from "../workspace/WorkspaceHeader.js"; +import { GraphTabPanel } from "../workspace/GraphTabPanel.js"; +import { TerminalTabPanel } from "../workspace/TerminalTabPanel.js"; +import { WorkspaceDialogs } from "../workspace/WorkspaceDialogs.js"; +import { loadCommitMessageGeneratorSettings } from "../commit-message-generator-settings.js"; +import { useAgentConfig } from "../hooks/useAgentConfig.js"; +import { useAvailableShells } from "../hooks/useAvailableShells.js"; +import { useRecentWorkspaces } from "../hooks/useRecentWorkspaces.js"; +import { useScaffoldKickoff } from "../hooks/useScaffoldKickoff.js"; +import { useWorktreeSelection } from "../hooks/useWorktreeSelection.js"; +import { useCommitDiffState } from "../hooks/useCommitDiffState.js"; +import { useTerminalManager } from "../hooks/useTerminalManager.js"; +import { useAutoUpdateListeners } from "../hooks/useAutoUpdateListeners.js"; +import { useWorkspaceFileWatchers } from "../hooks/useWorkspaceFileWatchers.js"; +import { useWorkspaceUiPersistence } from "../hooks/useWorkspaceUiPersistence.js"; +import { useEditorTabsForWorktree } from "../hooks/useEditorTabsForWorktree.js"; +import { useFileEditorActions } from "../hooks/useFileEditorActions.js"; +import { useRemoteOps } from "../hooks/useRemoteOps.js"; import { qk, useWorkspaceStatus, @@ -55,53 +46,18 @@ import { useCommitCount, useRefs, usePushStatus, - useFetch, - describeFetchSummary, - usePull, - usePush, useDeleteWorktree, useWorktreeChangeCounts, useIssueTrackerPatterns, useFileTree, useGithubAuthStatus, usePrStatuses, -} from '../queries.js'; +} from "../queries.js"; // ── Search params ───────────────────────────────────────────────────────────── type WorkspaceSearch = { path: string }; -/** Max characters of PTY output retained per terminal (~1MB of UTF-16). Older - * output is dropped from the front rather than kept forever — a terminal - * running a chatty long-lived process must not accumulate output for its - * entire lifetime. */ -const TERMINAL_BUFFER_CAP = 512 * 1024; - -// ── Shared helpers ──────────────────────────────────────────────────────────── - -/** Last path segment, tolerating both '/' (macOS/Linux) and '\' (Windows) separators. */ -function workspaceBaseName(p: string): string { - const trimmed = p.trim().replace(/[\\/]+$/g, ''); - const idx = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\')); - return idx === -1 ? trimmed : trimmed.slice(idx + 1); -} - -async function runSwitchAndTriggerHooks(args: { - workspacePath: string; - targetWorktreePath: string; - initiatingWorktreePath: string | null; - source: WorktreeSwitchHookSource; -}): Promise { - await api.runSwitchHooks(args); - await api.runTriggerHooks({ - workspacePath: args.workspacePath, - trigger: 'after_worktree_switch', - worktreePath: args.targetWorktreePath, - initiatingWorktreePath: args.initiatingWorktreePath, - source: args.source, - }); -} - // ── Workspace view ──────────────────────────────────────────────────────────── function WorkspaceView() { @@ -113,108 +69,59 @@ function WorkspaceView() { } function WorkspaceInner() { - const navigate = useNavigate(); const toast = useToast(); const qc = useQueryClient(); - const contextMenu = useContextMenu(); const { path: workspacePath } = useSearch({ from: workspaceRoute.id }); // ── Zustand UI state ────────────────────────────────────────────────── - const activeWorktree = useWorkspaceStore(s => s.activeWorktree); - const activeTab = useWorkspaceStore(s => s.activeTab); - const defaultShell = useWorkspaceStore(s => s.defaultShell); - const fetching = useWorkspaceStore(s => s.fetching); - const pulling = useWorkspaceStore(s => s.pulling); - const pushing = useWorkspaceStore(s => s.pushing); - const terminalSessions = useWorkspaceStore(s => s.terminalSessions); - const activeTerminalId = useWorkspaceStore(s => s.activeTerminalId); - const terminalLayout = useWorkspaceStore(s => s.terminalLayout); - const worktreeActiveTerminalId = useWorkspaceStore(s => s.worktreeActiveTerminalId); - const creatingWorktree = useWorkspaceStore(s => s.creatingWorktree); - const pendingCreationBranch = useWorkspaceStore(s => s.pendingCreationBranch); - const { updateState, setUpdateState } = useUpdateStore(); - - // Sessions for the currently selected worktree. All other sessions keep - // their PTYs running in the background and reappear when you switch back. - const visibleSessions = terminalSessions.filter(s => s.cwd === activeWorktree?.path); + const activeWorktree = useWorkspaceStore((s) => s.activeWorktree); + const activeTab = useWorkspaceStore((s) => s.activeTab); + const defaultShell = useWorkspaceStore((s) => s.defaultShell); + const fetching = useWorkspaceStore((s) => s.fetching); + const pulling = useWorkspaceStore((s) => s.pulling); + const pushing = useWorkspaceStore((s) => s.pushing); + const terminalSessions = useWorkspaceStore((s) => s.terminalSessions); + const creatingWorktree = useWorkspaceStore((s) => s.creatingWorktree); + const pendingCreationBranch = useWorkspaceStore( + (s) => s.pendingCreationBranch, + ); + const updateState = useAutoUpdateListeners(); // ── File editor tabs (Zustand) ───────────────────────────────────────── - const editorTabs = useEditorStore(s => s.tabs); - const editorTabOrder = useEditorStore(s => s.tabOrder); - const editorActiveTabKeyRaw = useEditorStore(s => s.activeTabKey); - const editorTabsForActiveWorktree = editorTabOrder - .map(k => editorTabs[k]) - .filter((t): t is NonNullable => !!t && t.worktreePath === activeWorktree?.path); - const editorActiveTabKey = editorTabsForActiveWorktree.some(t => tabKey(t.worktreePath, t.relativePath) === editorActiveTabKeyRaw) - ? editorActiveTabKeyRaw - : null; - const activeEditorTab = editorTabsForActiveWorktree.find(t => tabKey(t.worktreePath, t.relativePath) === editorActiveTabKey) ?? null; + const { editorTabsForActiveWorktree, editorActiveTabKey, activeEditorTab } = + useEditorTabsForWorktree(activeWorktree?.path); + const { openFile, saveFile, reloadFileFromDisk } = useFileEditorActions({ + activeWorktree, + toast, + }); // ── Shell picker ────────────────────────────────────────────────────── - const [availableShells, setAvailableShells] = useState<{ name: string; path: string }[]>([]); - const [showShellPicker, setShowShellPicker] = useState(false); - - useEffect(() => { - // Intentionally silent: worst case the shell picker just shows no options. - void api.listShells().then(setAvailableShells).catch(() => undefined); - }, []); + const availableShells = useAvailableShells(); // ── Coding agent ────────────────────────────────────────────────────── - const [agentConfig, setAgentConfig] = useState(null); - - useEffect(() => { - // A failure here leaves the Chat tab looking unconfigured with no - // indication why — surface it instead. - void api.getAgentConfig().then(setAgentConfig).catch((err: unknown) => reportError('Failed to load agent configuration', err)); - }, []); - const agentConfigured = !!agentConfig?.command.trim(); - - // ── Scaffold kickoff for a project just created from the homescreen's - // "New from idea" flow — see pending-scaffold.ts. Keyed by workspacePath - // (not the worktree path — git reports that back through its own realpath - // resolution, e.g. macOS's /var → /private/var, which would never match - // the locally-constructed path the dialog stored it under). Consumed at - // most once per workspace: harmless to re-run this effect (e.g. on a - // worktrees refetch), since consumePendingScaffold() returns undefined - // thereafter. - const [chatAutoPrompt, setChatAutoPrompt] = useState(undefined); - - useEffect(() => { - if (!activeWorktree || !agentConfig) return; - const prompt = consumePendingScaffold(workspacePath); - if (!prompt) return; - if (agentConfig.mode === 'integrated') { - setChatAutoPrompt(prompt); - useWorkspaceStore.setState({ activeTab: 'chat' }); - return; - } - void (async () => { - try { - const terminalId = await api.launchAgent({ workspacePath, worktreePath: activeWorktree.path }); - // Give the CLI a moment to boot before "typing" the kickoff prompt. - setTimeout(() => { void api.writeTerminal(terminalId, `${prompt}\n`).catch(() => undefined); }, 1500); - } catch (err) { - toast(`Failed to launch agent for scaffolding: ${String(err)}`, 'error'); - } - })(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [activeWorktree, agentConfig, workspacePath]); + const { agentConfig, agentConfigured } = useAgentConfig(); + const chatAutoPrompt = useScaffoldKickoff({ + activeWorktree, + agentConfig, + workspacePath, + toast, + }); // Worktree paths that currently have a live agent-launched terminal session. const worktreesWithLiveAgent = new Set( - terminalSessions.filter(s => s.agentId !== null).map(s => s.cwd), + terminalSessions.filter((s) => s.agentId !== null).map((s) => s.cwd), ); // ── Server state via TanStack Query ────────────────────────────────── const { data: workspaceStatus } = useWorkspaceStatus(workspacePath); // Use '' (falsy) until workspaceStatus resolves so dependent queries stay // disabled — workspacePath itself is not a git repo in the .sproutgit layout. - const gitRepoPath = workspaceStatus?.gitRepoPath ?? ''; + const gitRepoPath = workspaceStatus?.gitRepoPath ?? ""; - const { - data: worktrees = [], - isLoading: worktreesLoading, - } = useWorktrees(gitRepoPath, workspaceStatus?.worktreesPath); + const { data: worktrees = [], isLoading: worktreesLoading } = useWorktrees( + gitRepoPath, + workspaceStatus?.worktreesPath, + ); const { data: commits = [], @@ -225,8 +132,12 @@ function WorkspaceInner() { const { data: commitTotal = 0 } = useCommitCount(gitRepoPath); const { data: refs = [] } = useRefs(gitRepoPath); const { data: pushStatus } = usePushStatus(activeWorktree?.path); - const { data: issueTrackerPatterns = [] } = useIssueTrackerPatterns(activeWorktree?.path); - const { data: fileTree = [], isLoading: fileTreeLoading } = useFileTree(activeWorktree?.path); + const { data: issueTrackerPatterns = [] } = useIssueTrackerPatterns( + activeWorktree?.path, + ); + const { data: fileTree = [], isLoading: fileTreeLoading } = useFileTree( + activeWorktree?.path, + ); const loading = worktreesLoading || commitsLoading; @@ -238,72 +149,13 @@ function WorkspaceInner() { const { data: githubAuth } = useGithubAuthStatus(); const githubConnected = githubAuth?.authenticated ?? false; const prStatuses = usePrStatuses(worktrees, rootP, githubConnected); - const [createPrTarget, setCreatePrTarget] = useState(null); - - // ── Pick initial active worktree once worktrees load ───────────────── - const [pendingNewWorktreePath, setPendingNewWorktreePath] = useState(null); - - useEffect(() => { - // Filter out root worktree — it should never be active - const selectableWorktrees = worktrees.filter(w => w.path !== rootP); - if (selectableWorktrees.length === 0) { - useWorkspaceStore.setState({ activeWorktree: null }); - return; - } - - const workspaceChanged = lastWorktreeWorkspaceRef.current !== workspacePath; - lastWorktreeWorkspaceRef.current = workspacePath; - - // If the workspace hasn't changed, preserve an already-valid selection - // (e.g. a new worktree was added/removed — don't reset the active one). - if (!workspaceChanged) { - // If a new worktree was just created, switch to it automatically. - if (pendingNewWorktreePath) { - const newWt = selectableWorktrees.find(w => w.path === pendingNewWorktreePath); - if (newWt) { - const prevPath = useWorkspaceStore.getState().activeWorktree?.path ?? null; - setPendingNewWorktreePath(null); - useWorkspaceStore.setState(s => ({ - activeWorktree: newWt, - creatingWorktree: false, - pendingCreationBranch: null, - worktreeActiveTerminalId: { - ...s.worktreeActiveTerminalId, - ...(prevPath ? { [prevPath]: s.activeTerminalId } : {}), - }, - activeTerminalId: s.worktreeActiveTerminalId[newWt.path] ?? null, - })); - void runSwitchAndTriggerHooks({ workspacePath, targetWorktreePath: newWt.path, initiatingWorktreePath: prevPath, source: 'create' }) - .catch((err: unknown) => toast(`Switch hooks failed: ${String(err)}`, 'error')); - return; - } - } - const current = useWorkspaceStore.getState().activeWorktree; - if (current && selectableWorktrees.some(w => w.path === current.path)) return; - } + const [createPrTarget, setCreatePrTarget] = useState( + null, + ); - // On workspace open: restore the last-selected worktree from the DB, fall - // back to first non-detached, then first overall. - void api.getWorkspaceState(workspacePath, 'activeWorktreePath').then(saved => { - const restored = saved ? (selectableWorktrees.find(w => w.path === saved) ?? null) : null; - const initial = restored ?? selectableWorktrees.find(w => !w.detached) ?? selectableWorktrees[0] ?? null; - useWorkspaceStore.setState({ activeWorktree: initial }); - if (initial) { - void runSwitchAndTriggerHooks({ workspacePath, targetWorktreePath: initial.path, initiatingWorktreePath: null, source: 'load' }) - .catch((err: unknown) => toast(`Switch hooks failed: ${String(err)}`, 'error')); - } - }).catch(() => { - const initial = selectableWorktrees.find(w => !w.detached) ?? selectableWorktrees[0] ?? null; - useWorkspaceStore.setState({ activeWorktree: initial }); - if (initial) { - void runSwitchAndTriggerHooks({ workspacePath, targetWorktreePath: initial.path, initiatingWorktreePath: null, source: 'load' }) - .catch((err: unknown) => toast(`Switch hooks failed: ${String(err)}`, 'error')); - } - }); - // toast is recreated every render (see toast-context.tsx) — omit it so - // this effect only reruns when the worktree selection actually changes. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [worktrees, rootP, workspacePath, pendingNewWorktreePath]); + // ── Recent workspaces (for the title bar's workspace switcher) ───────── + const { loadRecentWorkspaces, switchWorkspace } = + useRecentWorkspaces(workspacePath); // ── Local UI state ──────────────────────────────────────────────────── const [hooksModalOpen, setHooksModalOpen] = useState(false); @@ -312,489 +164,44 @@ function WorkspaceInner() { const [deleteTarget, setDeleteTarget] = useState(null); const [showNewWorktree, setShowNewWorktree] = useState(false); const [sessionsPanelOpen, setSessionsPanelOpen] = useState(false); - const [sidebarCollapsed, setSidebarCollapsed] = useState(() => sessionStorage.getItem('sg_sidebar_collapsed') === '1'); - const [recentWorkspaces, setRecentWorkspaces] = useState([]); - - // Commit diff state - const [selectedCommits, setSelectedCommits] = useState([]); - const [commitDiffRange, setCommitDiffRange] = useState(null); - const [commitDiffFiles, setCommitDiffFiles] = useState([]); - const [commitDiffContent, setCommitDiffContent] = useState(''); - const [commitDiffFile, setCommitDiffFile] = useState(null); - const [commitDiffLoading, setCommitDiffLoading] = useState(false); - const [commitDiffFileLoading, setCommitDiffFileLoading] = useState(false); - const [renamingTerminalId, setRenamingTerminalId] = useState(null); - const [renameValue, setRenameValue] = useState(''); + const [sidebarCollapsed, setSidebarCollapsed] = useState( + () => sessionStorage.getItem("sg_sidebar_collapsed") === "1", + ); // Temporary shim — StagingPanel still uses this until it is refactored to useQuery const [stagingRefresh, setStagingRefresh] = useState(0); - // Non-reactive terminal data buffer. Capped per terminal so a long-running - // or repeatedly-opened terminal doesn't accumulate unbounded output for - // the lifetime of the session — see TERMINAL_BUFFER_CAP below. - const terminalDataRef = useRef>(new Map()); - // Cumulative characters trimmed from the front of each terminal's buffer, - // so TerminalPane (which reads pendingData as an ever-growing stream) can - // still compute correct write deltas after a trim. - const terminalDroppedLenRef = useRef>(new Map()); - const terminalBuffersHydratedRef = useRef(false); - // The store's terminalSessions survive WorkspaceInner unmounting/remounting - // (e.g. navigating to Projects and back) so background PTYs keep their - // pendingData/droppedLen, but these refs do not — they'd otherwise reset to - // empty and the next onTerminalData event would clobber the preserved - // buffer with just the newest chunk. Reseed once per mount, synchronously - // during render so it happens before the onTerminalData listener attaches. - if (!terminalBuffersHydratedRef.current) { - terminalBuffersHydratedRef.current = true; - for (const sess of useWorkspaceStore.getState().terminalSessions) { - terminalDataRef.current.set(sess.id, sess.pendingData); - terminalDroppedLenRef.current.set(sess.id, sess.droppedLen); - } - } - const renameInputRef = useRef(null); - const lastWorktreeWorkspaceRef = useRef(''); - - const selectedCommit = selectedCommits[0] ?? null; - // ── Mutations ───────────────────────────────────────────────────────── - const fetchMutation = useFetch(activeWorktree?.path ?? '', gitRepoPath); - const pullMutation = usePull(activeWorktree?.path ?? '', gitRepoPath); - const pushMutation = usePush(activeWorktree?.path ?? ''); const deleteWorktreeMutation = useDeleteWorktree(gitRepoPath); + const { doFetch, doPull, doPush } = useRemoteOps({ + activeWorktreePath: activeWorktree?.path, + gitRepoPath, + pushStatus, + toast, + onPushNeedsPublish: () => setShowPublishModal(true), + }); - // ── Reset UI state when workspace path changes ──────────────────────── - - useEffect(() => { - resetWorkspaceStore(workspacePath); - }, [workspacePath]); - - // ── Recent workspaces (for the title bar's workspace switcher) ───────── - - async function loadRecentWorkspaces(): Promise { - try { - const ws = await api.listRecentWorkspaces(); - const sorted = [...ws].sort((a, b) => new Date(b.lastOpenedAt).getTime() - new Date(a.lastOpenedAt).getTime()); - setRecentWorkspaces(sorted); - return sorted; - } catch { - return recentWorkspaces; - } - } - - useEffect(() => { - void loadRecentWorkspaces(); - // loadRecentWorkspaces is redefined every render (it closes over - // recentWorkspaces for its error fallback) — only re-run on navigation. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [workspacePath]); - - function switchWorkspace(path: string) { - if (path === workspacePath) return; - void api.addRecentWorkspace(path); - void navigate({ to: '/workspace', search: { path } }); - } - - // ── Default shell preference ────────────────────────────────────────── - useEffect(() => { - void api.getSetting('default_shell') - .then((v: string | null) => useWorkspaceStore.setState({ defaultShell: v ?? '' })) - .catch(() => undefined); - }, [workspacePath]); - - // ── MCP server auto-start ──────────────────────────────────────────── - // No-op unless the user previously enabled MCP for this workspace in - // Settings — this just makes "enabled" survive across app restarts. - // Gated on gitRepoPath for the same reason as the file watcher below: - // checking/persisting MCP state touches .sproutgit/state.db, which would - // otherwise get created (via openWorkspaceDb's side effect) inside a - // not-yet-migrated plain repo and make its working tree look dirty right - // as the legacy-layout bare-repo migration runs. - // Intentionally no stop-on-unmount: like background terminal sessions, - // the server should keep running while the app is open even after - // navigating away from this workspace view; it's torn down on workspace - // close (WORKSPACE_CLOSE) and app quit instead. - useEffect(() => { - if (!gitRepoPath) return; - // A failure here means the MCP server just doesn't start, invisibly — - // surface it instead. - void api.mcpEnsureStarted(workspacePath).catch((err: unknown) => reportError('Failed to start MCP server', err)); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [gitRepoPath]); - - // ── Close terminals when switching to a DIFFERENT workspace ────────── - // We use a ref so the cleanup only fires when the path genuinely changes - // (not when the component unmounts on navigation to the Projects screen). - const prevWorkspacePathRef = useRef(workspacePath); - useEffect(() => { - const prevPath = prevWorkspacePathRef.current; - prevWorkspacePathRef.current = workspacePath; - if (prevPath && prevPath !== workspacePath) { - void api.closeTerminalsForPath(prevPath); - } - }, [workspacePath]); - - // ── File watcher → invalidate queries ──────────────────────────────── - // Wait for gitRepoPath (root, always bare) to be known before watching — - // starting earlier would watch the pre-migration path, and on Windows - // `fs.watch` holds an open handle that blocks the rename that converts a - // workspace to the bare-root layout (EPERM). - - useEffect(() => { - if (!gitRepoPath) return; - // A failure here means the app silently stops picking up external - // changes (branch switches, commits, worktree adds from another tool) - // for the whole session — surface it rather than letting the workspace - // look "stuck"/stale with no explanation. - void api.startWatching(gitRepoPath).catch((err: unknown) => - toast(`Failed to watch workspace for changes: ${String(err)}`, 'error')); - const offWorktree = api.onWorktreeChanged(() => { - void qc.invalidateQueries({ queryKey: qk.worktrees(gitRepoPath) }); - }); - const offRefs = api.onGitRefsChanged(() => { - void qc.invalidateQueries({ queryKey: qk.commits(gitRepoPath) }); - void qc.invalidateQueries({ queryKey: qk.refs(gitRepoPath) }); - }); - return () => { - void api.stopWatching(gitRepoPath).catch(() => undefined); - offWorktree(); - offRefs(); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [gitRepoPath]); - - // ── File content watcher (active worktree) → file tree + open tab live-sync ── - // Distinct from the workspace-level watcher above (which only tracks .git/* - // for branch/ref changes) — this one covers the working tree's file - // *contents* so the file browser refreshes and open editor tabs pick up - // external edits (e.g. an AI agent or another tool writing to a file). - // - // It also doubles as a fast path for the Changes tab's status query: any - // content change likely means `git status` changed too, so invalidate it - // immediately instead of waiting on its 15s poll (kept as a fallback for - // changes this watcher can't see, e.g. an index/stash operation from - // another tool with no working-tree file write). - useEffect(() => { - const worktreePath = activeWorktree?.path; - if (!worktreePath) return; - - void api.startFileWatching(worktreePath); - const offFileChanged = api.onFileChanged((event: FileChangedEvent) => { - if (event.worktreePath !== worktreePath) return; - void qc.invalidateQueries({ queryKey: qk.fileTree(worktreePath) }); - void qc.invalidateQueries({ queryKey: qk.worktreeStatus(worktreePath) }); - if (gitRepoPath) void qc.invalidateQueries({ queryKey: qk.worktreeChangeCounts(gitRepoPath) }); - - const key = tabKey(worktreePath, event.relativePath); - if (!useEditorStore.getState().tabs[key]) return; - - if (event.type === 'deleted') return; // leave the buffer as-is; save will recreate the file - - api.readFile(worktreePath, event.relativePath) - .then(result => { - // Re-read the tab's current state rather than the one captured - // before this async read started — it may have been saved, - // reloaded, or closed while the read was in flight. - const currentTab = useEditorStore.getState().tabs[key]; - if (!currentTab) return; - if (result.mtimeMs <= currentTab.knownMtimeMs) return; - handleExternalChange(key, result.content, result.mtimeMs); - }) - .catch(() => undefined); - }); - - return () => { - void api.stopFileWatching(worktreePath); - offFileChanged(); - }; - }, [activeWorktree?.path, gitRepoPath, qc]); - - // ── Refresh worktrees when the window regains focus ─────────────────── - // Catches worktrees an external tool (e.g. Claude Code) registered while - // this window was unfocused, on top of the filesystem watcher above. - useEffect(() => { - if (!gitRepoPath) return; - const onFocus = () => void qc.invalidateQueries({ queryKey: qk.worktrees(gitRepoPath) }); - window.addEventListener('focus', onFocus); - return () => window.removeEventListener('focus', onFocus); - }, [gitRepoPath, qc]); - - // ── Drop worktree_metadata rows for worktrees that have disappeared ──── - // Best-effort bookkeeping cleanup only — never runs `git worktree prune`. - useEffect(() => { - if (!workspacePath || worktrees.length === 0) return; - void api.pruneWorktreeMetadata({ - workspacePath, - activeWorktreePaths: worktrees.map(w => w.path), - }).catch(() => undefined); - }, [workspacePath, worktrees]); - - // ── Session persistence ─────────────────────────────────────────────── - - useEffect(() => { - if (activeWorktree) sessionStorage.setItem('sg_active_wt', activeWorktree.path); - }, [activeWorktree]); - - // ── Auto-switch tab if activeTab becomes disabled ───────────────────── - useEffect(() => { - if (!activeWorktree && (activeTab === 'staging' || activeTab === 'terminal' || activeTab === 'chat' || activeTab === 'files')) { - useWorkspaceStore.setState({ activeTab: 'graph' }); - } - if (activeTab === 'chat' && agentConfig && agentConfig.mode !== 'integrated') { - useWorkspaceStore.setState({ activeTab: 'graph' }); - } - }, [activeWorktree, activeTab, agentConfig]); - - useEffect(() => { - sessionStorage.setItem('sg_active_tab', activeTab); - }, [activeTab]); - - useEffect(() => { - sessionStorage.setItem('sg_terminal_layout', terminalLayout); - }, [terminalLayout]); - - useEffect(() => { - sessionStorage.setItem('sg_sidebar_collapsed', sidebarCollapsed ? '1' : '0'); - }, [sidebarCollapsed]); - - // ── Cmd/Ctrl+B toggles the worktree sidebar ("work mode") ────────────── - useEffect(() => { - function onKey(e: KeyboardEvent) { - if (!(e.metaKey || e.ctrlKey) || e.key.toLowerCase() !== 'b') return; - // Don't hijack Cmd/Ctrl+B while the user is typing (e.g. it's "bold" in - // a rich-text/contentEditable field, or a meaningful character in a - // terminal/input) or if another handler already consumed the event. - if (e.defaultPrevented) return; - const target = e.target; - if (target instanceof HTMLElement) { - const tag = target.tagName; - if (tag === 'INPUT' || tag === 'TEXTAREA' || target.isContentEditable) return; - } - e.preventDefault(); - setSidebarCollapsed(v => !v); - } - window.addEventListener('keydown', onKey); - return () => window.removeEventListener('keydown', onKey); - }, []); - - useEffect(() => { - // If the active terminal was closed (not in any session), fall back to - // the last visible session for the current worktree. - if (activeTerminalId && !terminalSessions.some(s => s.id === activeTerminalId)) { - const activePath = useWorkspaceStore.getState().activeWorktree?.path; - const last = terminalSessions.filter(s => s.cwd === activePath).at(-1)?.id ?? null; - useWorkspaceStore.setState({ activeTerminalId: last }); - } - if (renamingTerminalId && !terminalSessions.some(s => s.id === renamingTerminalId)) { - setRenamingTerminalId(null); - setRenameValue(''); - } - }, [activeTerminalId, renamingTerminalId, terminalSessions]); - - useEffect(() => { - if (!renamingTerminalId || !renameInputRef.current) return; - renameInputRef.current.focus(); - renameInputRef.current.select(); - }, [renamingTerminalId]); - - // ── Terminal IPC ────────────────────────────────────────────────────── - - useEffect(() => { - const offData = api.onTerminalData((id: string, data: string) => { - const prevBuffer = terminalDataRef.current.get(id) ?? ''; - const prevDropped = terminalDroppedLenRef.current.get(id) ?? 0; - let buffer: string; - let droppedLen: number; - if (data.length >= TERMINAL_BUFFER_CAP) { - // A single chunk alone meets/exceeds the cap — skip concatenating it - // with the old buffer so we never allocate a temporary string larger - // than the cap (the old buffer is entirely superseded anyway). - const overflow = data.length - TERMINAL_BUFFER_CAP; - buffer = data.slice(overflow); - droppedLen = prevDropped + prevBuffer.length + overflow; - } else { - const combined = prevBuffer + data; - if (combined.length > TERMINAL_BUFFER_CAP) { - const overflow = combined.length - TERMINAL_BUFFER_CAP; - buffer = combined.slice(overflow); - droppedLen = prevDropped + overflow; - } else { - buffer = combined; - droppedLen = prevDropped; - } - } - terminalDataRef.current.set(id, buffer); - terminalDroppedLenRef.current.set(id, droppedLen); - useWorkspaceStore.setState(s => ({ - terminalSessions: s.terminalSessions.map(sess => - sess.id === id ? { ...sess, pendingData: buffer, droppedLen } : sess - ), - })); - }); - - const offExit = api.onTerminalExit((id: string) => { - terminalDataRef.current.delete(id); - terminalDroppedLenRef.current.delete(id); - useWorkspaceStore.setState(s => { - const remaining = s.terminalSessions.filter(sess => sess.id !== id); - const currentPath = s.activeWorktree?.path; - const visibleRemaining = remaining.filter(sess => sess.cwd === currentPath); - return { - terminalSessions: remaining, - activeTerminalId: s.activeTerminalId === id - ? (visibleRemaining.at(-1)?.id ?? null) - : s.activeTerminalId, - }; - }); - }); - - return () => { offData(); offExit(); }; - }, []); - - // ── Hook terminal launch listener ───────────────────────────────────── - - useEffect(() => { - const offHookTerminal = api.onHookTerminalLaunch((event) => { - const label = `hook: ${event.hookName}`; - useWorkspaceStore.setState(s => { - const cwd = event.cwd; - return { - terminalSessions: [...s.terminalSessions, { - id: event.terminalId, - cwd, - label: makeTerminalLabel(s.terminalSessions.filter(sess => sess.cwd === cwd), label), - pendingData: '', - droppedLen: 0, - agentId: null, - }], - activeTerminalId: event.terminalId, - activeTab: 'terminal', - worktreeActiveTerminalId: { - ...s.worktreeActiveTerminalId, - [cwd]: event.terminalId, - }, - }; - }); - }); - - return () => { offHookTerminal(); }; - }, []); - - // ── Agent terminal launch listener ───────────────────────────────────── - - useEffect(() => { - const offAgentTerminal = api.onAgentTerminalLaunch((event) => { - useWorkspaceStore.setState(s => { - const cwd = event.cwd; - return { - terminalSessions: [...s.terminalSessions, { - id: event.terminalId, - cwd, - label: makeTerminalLabel(s.terminalSessions.filter(sess => sess.cwd === cwd), 'AI Agent'), - pendingData: '', - droppedLen: 0, - agentId: 'agent', - }], - activeTerminalId: event.terminalId, - activeTab: 'terminal', - worktreeActiveTerminalId: { - ...s.worktreeActiveTerminalId, - [cwd]: event.terminalId, - }, - }; - }); - }); - - return () => { offAgentTerminal(); }; - }, []); - - // ── Auto-update listeners ───────────────────────────────────────────── - - useEffect(() => { - const offChecking = api.onUpdateChecking(() => setUpdateState({ status: 'checking' })); - const offAvailable = api.onUpdateAvailable((version: string) => setUpdateState({ status: 'available', version })); - const offNotAvailable = api.onUpdateNotAvailable(() => setUpdateState({ status: 'up-to-date' })); - const offDownloading = api.onUpdateDownloading((progress: number) => setUpdateState({ status: 'downloading', progress })); - const offReady = api.onUpdateReady(() => setUpdateState({ status: 'ready' })); - const offError = api.onUpdateError((message: string) => { - reportError('Update failed', message); - setUpdateState({ status: 'idle' }); - }); - return () => { offChecking(); offAvailable(); offNotAvailable(); offDownloading(); offReady(); offError(); }; - }, [setUpdateState]); - - // ── Actions ─────────────────────────────────────────────────────────── - - async function doFetch() { - if (!activeWorktree) return; - useWorkspaceStore.setState({ fetching: true }); - try { - const summary = await fetchMutation.mutateAsync(); - toast(describeFetchSummary(summary), summary.hadNoRemotes ? 'info' : 'success'); - } catch (err) { - toast(`Fetch failed: ${String(err)}`, 'error'); - } finally { - useWorkspaceStore.setState({ fetching: false }); - } - } - - async function doPull() { - if (!activeWorktree) return; - useWorkspaceStore.setState({ pulling: true }); - try { - await pullMutation.mutateAsync(); - toast('Pulled', 'success'); - } catch (err) { - toast(`Pull failed: ${String(err)}`, 'error'); - } finally { - useWorkspaceStore.setState({ pulling: false }); - } - } - - async function doPush() { - if (!activeWorktree) return; - if (!pushStatus?.upstream) { - setShowPublishModal(true); - return; - } - useWorkspaceStore.setState({ pushing: true }); - try { - await pushMutation.mutateAsync(); - toast('Pushed', 'success'); - } catch (err) { - toast(`Push failed: ${String(err)}`, 'error'); - } finally { - useWorkspaceStore.setState({ pushing: false }); - } - } - - async function handleWorktreeSwitch(wt: WorktreeInfo) { - if (activeWorktree?.path === wt.path) return; - const prevPath = activeWorktree?.path ?? null; - // Save the active terminal for the outgoing worktree and restore the - // last known active terminal for the incoming worktree. - useWorkspaceStore.setState(s => { - const savedForTarget = s.worktreeActiveTerminalId[wt.path] ?? null; - const visibleForTarget = s.terminalSessions.filter(sess => sess.cwd === wt.path); - const restoredId = savedForTarget && visibleForTarget.some(sess => sess.id === savedForTarget) - ? savedForTarget - : (visibleForTarget.at(-1)?.id ?? null); - return { - activeWorktree: wt, - activeTerminalId: restoredId, - worktreeActiveTerminalId: { - ...s.worktreeActiveTerminalId, - ...(prevPath ? { [prevPath]: s.activeTerminalId } : {}), - }, - }; - }); - void api.setWorkspaceState(workspacePath, 'activeWorktreePath', wt.path).catch(() => undefined); - void runSwitchAndTriggerHooks({ workspacePath, targetWorktreePath: wt.path, initiatingWorktreePath: prevPath, source: 'manual' }) - .catch((err: unknown) => toast(`Switch hooks failed: ${String(err)}`, 'error')); - } + // ── Worktree selection (initial pick, switch, delete, create-hooks) ─── + const { + setPendingNewWorktreePath, + handleWorktreeSwitch, + doDeleteWorktree, + runCreateHooksFor, + } = useWorktreeSelection({ + workspacePath, + worktrees, + rootPath: rootP, + gitRepoPath, + workspaceStatus, + activeWorktree, + deleteWorktreeMutation, + toast, + closeDeleteDialog: () => setDeleteTarget(null), + }); /** Jump-to-terminal action from the agent sessions dashboard: switches to * the session's worktree (if not already active) and focuses its terminal tab. */ function handleJumpToSession(session: TerminalInfo) { - const wt = worktrees.find(w => w.path === session.cwd); + const wt = worktrees.find((w) => w.path === session.cwd); // The dashboard already filters to this workspace's worktrees, but guard // here too — jumping to a session with no matching worktree would leave // activeTerminalId pointing at a tab that can never render. @@ -802,480 +209,85 @@ function WorkspaceInner() { if (wt.path !== activeWorktree?.path) { void handleWorktreeSwitch(wt); } - useWorkspaceStore.setState(s => ({ + useWorkspaceStore.setState((s) => ({ activeTerminalId: session.id, - activeTab: 'terminal', - worktreeActiveTerminalId: { ...s.worktreeActiveTerminalId, [session.cwd]: session.id }, + activeTab: "terminal", + worktreeActiveTerminalId: { + ...s.worktreeActiveTerminalId, + [session.cwd]: session.id, + }, })); setSessionsPanelOpen(false); } - async function doDeleteWorktree(wt: WorktreeInfo) { - const isDeletingActive = activeWorktree?.path === wt.path; - const nextWt = isDeletingActive - ? worktrees.find(w => w.path !== wt.path && w.path !== workspaceStatus?.rootPath) - ?? null - : null; - - try { - if (isDeletingActive && nextWt) { - try { - await api.runSwitchHooks({ - workspacePath, - targetWorktreePath: nextWt.path, - initiatingWorktreePath: wt.path, - source: 'delete', - }); - await api.runTriggerHooks({ - workspacePath, - trigger: 'after_worktree_switch', - worktreePath: nextWt.path, - initiatingWorktreePath: wt.path, - source: 'delete', - }); - } catch { /* non-critical */ } - } - - // before/after_worktree_remove hooks and terminal cleanup now run - // server-side as part of the worktree:delete IPC call itself (see - // app/src/main/worktree-lifecycle.ts). afterRemoveWorktreePath is the - // UI's own "next active worktree" concept (no MCP equivalent), passed - // through explicitly so the after-hook's env vars still reflect it. - const afterRemoveWorktreePath = (nextWt ?? activeWorktree)?.path ?? null; - - // Switch the active worktree away *before* the mutation so that no git - // queries fire on the deleted path while or after the deletion runs. - if (isDeletingActive) useWorkspaceStore.setState({ activeWorktree: nextWt }); - await deleteWorktreeMutation.mutateAsync({ - workspacePath, - rootRepoPath: gitRepoPath, - ...(workspaceStatus?.worktreesPath ? { managedWorktreesPath: workspaceStatus.worktreesPath } : {}), - worktreePath: wt.path, - // Never delete the branch of an external worktree — an external tool - // owns it. The main process re-enforces this guard server-side too. - deleteBranch: !wt.isExternal && !!wt.branch, - branchName: wt.branch ?? null, - initiatingWorktreePath: activeWorktree?.path ?? null, - afterRemoveWorktreePath, - }); - - toast('Worktree removed', 'success'); - } catch (err) { - toast(`Failed to remove worktree: ${String(err)}`, 'error'); - } finally { - setDeleteTarget(null); - } - } - - // after_worktree_create hooks never fire retroactively for a worktree we - // adopted rather than created — this lets the user opt in explicitly - // (e.g. to run a dependency install) from the worktree's context menu. - async function runCreateHooksFor(wt: WorktreeInfo) { - try { - await api.runCreateHooks({ - workspacePath, - newWorktreePath: wt.path, - initiatingWorktreePath: activeWorktree?.path ?? null, - }); - toast('Create hooks ran', 'success'); - } catch (err) { - toast(`Create hooks failed: ${String(err)}`, 'error'); - } - } - - async function loadCommitDiff(commit: CommitEntry) { - setSelectedCommits([commit]); - setCommitDiffFile(null); - setCommitDiffContent(''); - setCommitDiffLoading(true); - try { - const range = commit.parents.length > 0 - ? `${commit.parents[0]}..${commit.hash}` - : commit.hash; - setCommitDiffRange(range); - const files = await api.getDiffFiles(gitRepoPath, range); - setCommitDiffFiles(files as DiffFileEntry[]); - } catch (err) { - toast(`Failed to load commit diff: ${String(err)}`, 'error'); - setCommitDiffFiles([]); - } finally { - setCommitDiffLoading(false); - } - } - - async function loadCommitRangeDiff(from: CommitEntry, to: CommitEntry) { - setSelectedCommits([from, to]); - setCommitDiffFile(null); - setCommitDiffContent(''); - setCommitDiffLoading(true); - try { - const range = `${from.hash}..${to.hash}`; - setCommitDiffRange(range); - const files = await api.getDiffFiles(gitRepoPath, range); - setCommitDiffFiles(files as DiffFileEntry[]); - } catch (err) { - toast(`Failed to load range diff: ${String(err)}`, 'error'); - setCommitDiffFiles([]); - } finally { - setCommitDiffLoading(false); - } - } - - async function loadCommitDiffFile(file: DiffFileEntry) { - if (!selectedCommit) return; - setCommitDiffFile(file); - setCommitDiffFileLoading(true); - try { - const range = commitDiffRange ?? ( - selectedCommit.parents.length > 0 - ? `${selectedCommit.parents[0]}..${selectedCommit.hash}` - : selectedCommit.hash - ); - const content = await api.getDiffContent(gitRepoPath, range, file.path); - setCommitDiffContent(content as string); - } catch (err) { - toast(`Failed to load diff: ${String(err)}`, 'error'); - setCommitDiffContent(''); - } finally { - setCommitDiffFileLoading(false); - } - } - - // ── File editor tabs ────────────────────────────────────────────────── - - async function openFile(relativePath: string) { - if (!activeWorktree) return; - const worktreePath = activeWorktree.path; - const key = openOrFocusTab(worktreePath, relativePath); - useWorkspaceStore.setState({ activeTab: 'files' }); - const tab = useEditorStore.getState().tabs[key]; - if (!tab) return; - // "already loaded" means a load previously succeeded, not that the - // content happens to be non-empty — an actually-empty file would - // otherwise get re-read every time the tab is reopened/focused. - if (!tab.loading && !tab.error) return; - try { - const result = await api.readFile(worktreePath, relativePath); - setTabLoaded(key, result.content, result.mtimeMs); - } catch (err) { - setTabError(key, `Failed to read file: ${String(err)}`); - } - } - - async function saveFile(key: string) { - const tab = useEditorStore.getState().tabs[key]; - if (!tab || !tab.dirty) return; - try { - const result = await api.writeFile(tab.worktreePath, tab.relativePath, tab.content); - setTabSaved(key, tab.content, result.mtimeMs); - } catch (err) { - toast(`Failed to save ${tab.relativePath}: ${String(err)}`, 'error'); - } - } - - async function reloadFileFromDisk(key: string) { - const tab = useEditorStore.getState().tabs[key]; - if (!tab) return; - try { - const result = await api.readFile(tab.worktreePath, tab.relativePath); - resolveConflictReload(key, result.content, result.mtimeMs); - } catch (err) { - toast(`Failed to reload ${tab.relativePath}: ${String(err)}`, 'error'); - } - } - - async function openTerminal(cwd: string, label?: string, shellOverride?: string) { - try { - const terminalArgs: { - cwd: string; - label?: string; - shell?: string; - } = { cwd }; - if (label) terminalArgs.label = label; - const resolvedShell = shellOverride ?? defaultShell; - if (resolvedShell) terminalArgs.shell = resolvedShell; - const id = await api.createTerminal(terminalArgs); - const shellLabel = shellDisplayName(resolvedShell); - useWorkspaceStore.setState(s => ({ - terminalSessions: [...s.terminalSessions, { - id, - cwd, - label: makeTerminalLabel(s.terminalSessions.filter(sess => sess.cwd === cwd), label ?? shellLabel), - pendingData: '', - droppedLen: 0, - agentId: null, - }], - activeTerminalId: id, - activeTab: 'terminal', - worktreeActiveTerminalId: { ...s.worktreeActiveTerminalId, [cwd]: id }, - })); - } catch (err) { - toast(`Failed to open terminal: ${String(err)}`, 'error'); - } - } - - async function launchAgent(worktreePath: string) { - try { - await api.launchAgent({ workspacePath, worktreePath }); - } catch (err) { - toast(`Failed to launch agent: ${String(err)}`, 'error'); - } - } - - function shellDisplayName(shellPath: string | null | undefined): string { - if (!shellPath) return 'terminal'; - const base = shellPath.split(/[/\\]/).pop() ?? shellPath; - return base.replace(/\.exe$/i, ''); - } - - function makeTerminalLabel(sessions: typeof terminalSessions, baseLabel: string) { - const trimmed = baseLabel.trim() || 'terminal'; - const existing = sessions.filter(s => s.label === trimmed).length; - return existing === 0 ? trimmed : `${trimmed} (${existing + 1})`; - } - - function terminalPanelStyle(id: string): React.CSSProperties { - if (terminalLayout === 'tabs') { - return { - display: id === activeTerminalId ? 'block' : 'none', - minHeight: 0, - flex: 1, - }; - } - return { - display: 'block', - minHeight: 0, - minWidth: 0, - flex: 1, - }; - } - - function terminalWrapperClass() { - if (terminalLayout === 'split') return 'flex flex-1 min-h-0'; - if (terminalLayout === 'grid') return 'grid flex-1 min-h-0 grid-cols-2 auto-rows-fr'; - return 'flex flex-1 min-h-0 flex-col'; - } - - async function closeTerminal(id: string) { - await api.closeTerminal(id).catch(() => undefined); - terminalDataRef.current.delete(id); - terminalDroppedLenRef.current.delete(id); - useWorkspaceStore.setState(s => { - const remaining = s.terminalSessions.filter(sess => sess.id !== id); - const currentPath = s.activeWorktree?.path; - const visibleRemaining = remaining.filter(sess => sess.cwd === currentPath); - return { - terminalSessions: remaining, - activeTerminalId: s.activeTerminalId === id - ? (visibleRemaining.at(-1)?.id ?? null) - : s.activeTerminalId, - }; - }); - if (renamingTerminalId === id) { - setRenamingTerminalId(null); - setRenameValue(''); - } - } - - async function closeTerminals(ids: string[]) { - if (ids.length === 0) return; - await Promise.all(ids.map(id => api.closeTerminal(id).catch(() => undefined))); - for (const id of ids) { - terminalDataRef.current.delete(id); - terminalDroppedLenRef.current.delete(id); - } - useWorkspaceStore.setState(s => { - const idSet = new Set(ids); - const remaining = s.terminalSessions.filter(sess => !idSet.has(sess.id)); - const currentPath = s.activeWorktree?.path; - const visibleRemaining = remaining.filter(sess => sess.cwd === currentPath); - return { - terminalSessions: remaining, - activeTerminalId: idSet.has(s.activeTerminalId ?? '') - ? (visibleRemaining.at(-1)?.id ?? null) - : s.activeTerminalId, - }; - }); - if (renamingTerminalId && ids.includes(renamingTerminalId)) { - setRenamingTerminalId(null); - setRenameValue(''); - } - } - - function startTerminalRename(id: string) { - const session = terminalSessions.find(s => s.id === id); - if (!session) return; - useWorkspaceStore.setState({ activeTerminalId: id, activeTab: 'terminal' }); - setRenamingTerminalId(id); - setRenameValue(session.label); - } - - function commitTerminalRename() { - if (!renamingTerminalId) return; - const trimmed = renameValue.trim(); - if (trimmed) { - useWorkspaceStore.setState(s => ({ - terminalSessions: s.terminalSessions.map(sess => - sess.id === renamingTerminalId ? { ...sess, label: trimmed } : sess, - ), - })); - } - setRenamingTerminalId(null); - setRenameValue(''); - } - - function cancelTerminalRename() { - setRenamingTerminalId(null); - setRenameValue(''); - } + // ── Commit diff state ────────────────────────────────────────────────── + const { + selectedCommits, + selectedCommit, + commitDiffFiles, + commitDiffContent, + commitDiffFile, + commitDiffLoading, + commitDiffFileLoading, + loadCommitDiff, + loadCommitRangeDiff, + loadCommitDiffFile, + clearCommitDiff, + } = useCommitDiffState({ gitRepoPath, toast }); + + // ── Terminal sessions ────────────────────────────────────────────────── + const terminalManager = useTerminalManager({ + workspacePath, + activeWorktreePath: activeWorktree?.path, + defaultShell, + toast, + }); - function openTerminalTabMenu(e: React.MouseEvent, sessionId: string) { - const index = visibleSessions.findIndex(s => s.id === sessionId); - if (index === -1) return; - const idsToRight = visibleSessions.slice(index + 1).map(s => s.id); - const otherIds = visibleSessions.filter(s => s.id !== sessionId).map(s => s.id); - contextMenu.open(e, [ - { - label: 'Rename', - icon: , - onClick: () => startTerminalRename(sessionId), - }, - { - label: 'New Terminal', - icon: , - onClick: () => { - void openTerminal(activeWorktree?.path ?? workspacePath); - }, - }, - 'separator', - { - label: 'Tabbed Layout', - icon: , - onClick: () => useWorkspaceStore.setState({ terminalLayout: 'tabs', activeTerminalId: sessionId, activeTab: 'terminal' }), - }, - { - label: 'Split Layout', - icon: , - onClick: () => useWorkspaceStore.setState({ terminalLayout: 'split', activeTerminalId: sessionId, activeTab: 'terminal' }), - }, - { - label: 'Grid Layout', - icon: , - onClick: () => useWorkspaceStore.setState({ terminalLayout: 'grid', activeTerminalId: sessionId, activeTab: 'terminal' }), - }, - 'separator', - { - label: 'Close', - icon: , - danger: true, - onClick: () => { void closeTerminal(sessionId); }, - }, - { - label: 'Close Others', - icon: , - disabled: otherIds.length === 0, - danger: true, - onClick: () => { void closeTerminals(otherIds); }, - }, - { - label: 'Close To Right', - icon: , - disabled: idsToRight.length === 0, - danger: true, - onClick: () => { void closeTerminals(idsToRight); }, - }, - ]); - } + // ── Background watchers / subscriptions ─────────────────────────────── + useWorkspaceFileWatchers({ + workspacePath, + gitRepoPath, + activeWorktreePath: activeWorktree?.path, + worktrees, + qc, + toast, + }); + useWorkspaceUiPersistence({ + workspacePath, + activeWorktree, + activeTab, + terminalLayout: terminalManager.terminalLayout, + agentConfig, + sidebarCollapsed, + setSidebarCollapsed, + }); // ── Style helpers ───────────────────────────────────────────────────── function tabCls(active: boolean, disabled: boolean = false) { - return `sg-tab flex items-center gap-1.5 px-3 h-full text-xs cursor-pointer bg-transparent border-t-0 border-x-0 border-b-2 transition-colors whitespace-nowrap ${disabled - ? 'text-(--sg-text-dim) border-transparent cursor-not-allowed opacity-50' + return `sg-tab flex items-center gap-1.5 px-3 h-full text-xs cursor-pointer bg-transparent border-t-0 border-x-0 border-b-2 transition-colors whitespace-nowrap ${ + disabled + ? "text-(--sg-text-dim) border-transparent cursor-not-allowed opacity-50" : active - ? 'text-(--sg-primary) border-(--sg-primary) font-medium cursor-pointer' - : 'text-(--sg-text-faint) border-transparent hover:text-(--sg-text) cursor-pointer' - }`; + ? "text-(--sg-primary) border-(--sg-primary) font-medium cursor-pointer" + : "text-(--sg-text-faint) border-transparent hover:text-(--sg-text) cursor-pointer" + }`; } - const iconBtn = 'inline-flex items-center justify-center p-[3px] bg-transparent border-none cursor-pointer text-(--sg-text-faint) rounded-[4px] transition-colors hover:text-(--sg-text) hover:bg-(--sg-surface-raised) disabled:opacity-40 disabled:cursor-not-allowed'; - // ── Render ──────────────────────────────────────────────────────────── return ( <>
- {/* ── Full-width top header (matches home page style) ── */} -
- -
- -
- - - - - {activeWorktree?.branch ?? (activeWorktree?.detached ? 'detached' : '—')} - -
-
- void api.installUpdate()} /> - - - -
- -
+ 0} + onToggleSessionsPanel={() => setSessionsPanelOpen((v) => !v)} + /> {/* ── Body: sidebar + main content ── */}
@@ -1295,27 +307,34 @@ function WorkspaceInner() { pendingCreationBranch={pendingCreationBranch} updateState={updateState} collapsed={sidebarCollapsed} - onToggleCollapsed={() => setSidebarCollapsed(v => !v)} - onWorktreeSwitch={wt => void handleWorktreeSwitch(wt)} + onToggleCollapsed={() => setSidebarCollapsed((v) => !v)} + onWorktreeSwitch={(wt) => void handleWorktreeSwitch(wt)} onFetch={() => void doFetch()} onPull={() => void doPull()} onPush={() => void doPush()} onRefresh={() => { - void qc.invalidateQueries({ queryKey: qk.worktrees(gitRepoPath) }); + void qc.invalidateQueries({ + queryKey: qk.worktrees(gitRepoPath), + }); void qc.invalidateQueries({ queryKey: qk.commits(gitRepoPath) }); void qc.invalidateQueries({ queryKey: qk.refs(gitRepoPath) }); - if (activeWorktree) void qc.invalidateQueries({ queryKey: qk.pushStatus(activeWorktree.path) }); + if (activeWorktree) + void qc.invalidateQueries({ + queryKey: qk.pushStatus(activeWorktree.path), + }); }} onNewWorktree={() => setShowNewWorktree(true)} - onOpenTerminal={(cwd, label) => void openTerminal(cwd, label)} + onOpenTerminal={(cwd, label) => + void terminalManager.openTerminal(cwd, label) + } onOpenHooksModal={() => setHooksModalOpen(true)} - onOpenRunHookModal={wt => setRunHookTarget(wt)} - onRunCreateHooks={wt => void runCreateHooksFor(wt)} - onDeleteWorktree={wt => setDeleteTarget(wt)} + onOpenRunHookModal={(wt) => setRunHookTarget(wt)} + onRunCreateHooks={(wt) => void runCreateHooksFor(wt)} + onDeleteWorktree={(wt) => setDeleteTarget(wt)} agentConfigured={agentConfigured} worktreesWithLiveAgent={worktreesWithLiveAgent} - onLaunchAgent={wtPath => void launchAgent(wtPath)} - onCreatePr={wt => setCreatePrTarget(wt)} + onLaunchAgent={(wtPath) => void terminalManager.launchAgent(wtPath)} + onCreatePr={(wt) => setCreatePrTarget(wt)} /> {/* Main content */} @@ -1323,49 +342,55 @@ function WorkspaceInner() { {/* Tab bar */}
- {agentConfig?.mode === 'integrated' && ( + {agentConfig?.mode === "integrated" && ( )} - )} - -
- ); - })} -
-
- - {availableShells.length > 0 && ( - - )} - {showShellPicker && availableShells.length > 0 && ( - <> -
setShowShellPicker(false)} /> -
- {availableShells.map(shell => ( - - ))} -
- - )} -
-
-
- - - -
-
-
- {visibleSessions.map(s => ( -
- { void api.writeTerminal(id, data).catch(() => undefined); }} - onResize={(id, cols, rows) => { void api.resizeTerminal(id, cols, rows).catch(() => undefined); }} - /> -
- ))} -
-
+ {activeTab === "terminal" && activeWorktree && ( + + void terminalManager.openTerminal( + cwd, + label, + shellOverride, + ) + } + onCloseTerminal={(id) => + void terminalManager.closeTerminal(id) + } + onOpenTerminalTabMenu={(e, id) => + terminalManager.openTerminalTabMenu(e, id) + } + onSelectTerminal={(id) => + useWorkspaceStore.setState({ + activeTerminalId: id, + activeTab: "terminal", + }) + } + onCommitRename={terminalManager.commitTerminalRename} + onCancelRename={terminalManager.cancelTerminalRename} + onSetLayout={(layout) => + useWorkspaceStore.setState({ terminalLayout: layout }) + } + terminalPanelStyle={terminalManager.terminalPanelStyle} + terminalWrapperClass={ + terminalManager.terminalWrapperClass + } + /> )} {/* Chat tab (Integrated AI agent mode) */} - {activeTab === 'chat' && activeWorktree && agentConfig?.mode === 'integrated' && ( - - )} + {activeTab === "chat" && + activeWorktree && + agentConfig?.mode === "integrated" && ( + + )} {/* Files tab */} - {activeTab === 'files' && activeWorktree && ( + {activeTab === "files" && activeWorktree && (
void openFile(rel)} - onRefresh={() => void qc.invalidateQueries({ queryKey: qk.fileTree(activeWorktree.path) })} + activeRelativePath={ + activeEditorTab?.relativePath ?? null + } + onOpenFile={(rel) => void openFile(rel)} + onRefresh={() => + void qc.invalidateQueries({ + queryKey: qk.fileTree(activeWorktree.path), + }) + } />
tabKey(t.worktreePath, t.relativePath)} - onSelectTab={key => setActiveEditorTab(key)} - onCloseTab={key => closeEditorTab(key)} - onChange={(key, content) => setTabContent(key, content)} - onSave={key => void saveFile(key)} - onReloadFromDisk={key => void reloadFileFromDisk(key)} - onKeepMine={key => resolveConflictKeepMine(key)} + keyOf={(t) => tabKey(t.worktreePath, t.relativePath)} + onSelectTab={(key) => setActiveEditorTab(key)} + onCloseTab={(key) => closeEditorTab(key)} + onChange={(key, content) => + setTabContent(key, content) + } + onSave={(key) => void saveFile(key)} + onReloadFromDisk={(key) => + void reloadFileFromDisk(key) + } + onKeepMine={(key) => resolveConflictKeepMine(key)} />
@@ -1695,45 +593,33 @@ function WorkspaceInner() { )}
- {/* end body */} + + {/* end body */} {/* Agent sessions dashboard */} handleJumpToSession(session)} + onJump={(session) => handleJumpToSession(session)} onClose={() => setSessionsPanelOpen(false)} /> - {/* Hooks settings modal */} - setHooksModalOpen(false)} - {...(defaultShell ? { defaultShell } : {})} - api={{ - listHooks: (p, wt) => api.listHooks(p, wt), - createHook: args => api.createHook(args), - updateHook: args => api.updateHook(args), - deleteHook: (p, id) => api.deleteHook(p, id), - toggleHook: (p, id, enabled) => api.toggleHook(p, id, enabled), - trustHook: (wt, hookId) => api.trustHook(wt, hookId), - }} - /> - - {/* New worktree dialog */} - setShowNewWorktree(false)} - onCreated={(newWorktreePath) => { + pushStatus={pushStatus} + managedWorktreesPath={workspaceStatus?.worktreesPath} + toast={toast} + hooksModalOpen={hooksModalOpen} + onCloseHooksModal={() => setHooksModalOpen(false)} + showNewWorktree={showNewWorktree} + onCloseNewWorktree={() => setShowNewWorktree(false)} + onWorktreeCreated={(newWorktreePath) => { // Hooks (before/after_worktree_create) and issue-ref provenance now // run server-side as part of the worktree:create IPC call itself // (see app/src/main/worktree-lifecycle.ts) — the only thing left @@ -1742,44 +628,28 @@ function WorkspaceInner() { void qc.invalidateQueries({ queryKey: qk.worktrees(gitRepoPath) }); void qc.invalidateQueries({ queryKey: qk.refs(gitRepoPath) }); }} - onToast={(msg, v) => toast(msg, v)} - /> - - {/* Publish branch dialog */} - setShowPublishModal(false)} - onToast={(msg, v) => toast(msg, v)} - onPublished={() => activeWorktree && void qc.invalidateQueries({ queryKey: qk.pushStatus(activeWorktree.path) })} - /> - - {/* Create PR dialog */} - setCreatePrTarget(null)} - onToast={(msg, v) => toast(msg, v)} - onCreated={() => createPrTarget && void qc.invalidateQueries({ queryKey: qk.prStatus(createPrTarget.path) })} - /> - - {/* Run hook dialog */} - setRunHookTarget(null)} - onToast={(msg, v) => toast(msg, v)} - /> - - {/* Delete worktree dialog */} - void doDeleteWorktree(wt)} - onCancel={() => setDeleteTarget(null)} + showPublishModal={showPublishModal} + onClosePublishModal={() => setShowPublishModal(false)} + onPublished={() => + activeWorktree && + void qc.invalidateQueries({ + queryKey: qk.pushStatus(activeWorktree.path), + }) + } + createPrTarget={createPrTarget} + onCloseCreatePr={() => setCreatePrTarget(null)} + onPrCreated={() => + createPrTarget && + void qc.invalidateQueries({ + queryKey: qk.prStatus(createPrTarget.path), + }) + } + runHookTarget={runHookTarget} + onCloseRunHookModal={() => setRunHookTarget(null)} + deleteTarget={deleteTarget} + deleteLoading={deleteWorktreeMutation.isPending} + onConfirmDelete={(wt) => void doDeleteWorktree(wt)} + onCancelDelete={() => setDeleteTarget(null)} /> ); @@ -1787,9 +657,9 @@ function WorkspaceInner() { export const workspaceRoute = createRoute({ getParentRoute: () => rootRoute, - path: '/workspace', + path: "/workspace", validateSearch: (search: Record): WorkspaceSearch => ({ - path: typeof search['path'] === 'string' ? search['path'] : '', + path: typeof search["path"] === "string" ? search["path"] : "", }), component: WorkspaceView, }); diff --git a/app/src/renderer/stores/workspace-store.ts b/app/src/renderer/stores/workspace-store.ts index 831cec5..37d27e1 100644 --- a/app/src/renderer/stores/workspace-store.ts +++ b/app/src/renderer/stores/workspace-store.ts @@ -2,7 +2,7 @@ import { create } from 'zustand'; import type { WorktreeInfo } from '@sproutgit/types'; export type Tab = 'graph' | 'staging' | 'terminal' | 'chat' | 'files'; -type TerminalLayout = 'tabs' | 'split' | 'grid'; +export type TerminalLayout = 'tabs' | 'split' | 'grid'; type TerminalSession = { id: string; label: string; diff --git a/app/src/renderer/workspace/GraphTabPanel.tsx b/app/src/renderer/workspace/GraphTabPanel.tsx new file mode 100644 index 0000000..90d693a --- /dev/null +++ b/app/src/renderer/workspace/GraphTabPanel.tsx @@ -0,0 +1,154 @@ +import { CommitGraph } from "@sproutgit/ui"; +import { CommitDiffPanel } from "./CommitDiffPanel.js"; +import { api } from "../api.js"; +import { qk } from "../queries.js"; +import type { useQueryClient } from "@tanstack/react-query"; +import type { + CommitEntry, + DiffFileEntry, + IssueTrackerPattern, + WorktreeInfo, +} from "@sproutgit/types"; +import type { ToastFn } from "../toast-context.js"; + +type Props = { + commits: CommitEntry[]; + worktrees: WorktreeInfo[]; + activeWorktree: WorktreeInfo | null; + commitTotal: number; + commitsFetching: boolean; + gitRepoPath: string; + issueTrackerPatterns: IssueTrackerPattern[]; + qc: ReturnType; + toast: ToastFn; + onCreateWorktree: () => void; + + selectedCommits: CommitEntry[]; + selectedCommit: CommitEntry | null; + commitDiffFiles: DiffFileEntry[]; + commitDiffContent: string; + commitDiffFile: DiffFileEntry | null; + commitDiffLoading: boolean; + commitDiffFileLoading: boolean; + onSelectCommit: (commit: CommitEntry) => void; + onSelectCommitRange: (from: CommitEntry, to: CommitEntry) => void; + onClearCommitSelection: () => void; + onSelectDiffFile: (file: DiffFileEntry) => void; +}; + +/** Graph tab: commit graph plus, when a commit/range is selected, its diff panel. */ +export function GraphTabPanel({ + commits, + worktrees, + activeWorktree, + commitTotal, + commitsFetching, + gitRepoPath, + issueTrackerPatterns, + qc, + toast, + onCreateWorktree, + selectedCommits, + selectedCommit, + commitDiffFiles, + commitDiffContent, + commitDiffFile, + commitDiffLoading, + commitDiffFileLoading, + onSelectCommit, + onSelectCommitRange, + onClearCommitSelection, + onSelectDiffFile, +}: Props) { + return ( +
+
+ 0} + onLoadMore={async () => { + const more = (await api.getCommitGraph({ + repoPath: gitRepoPath, + limit: 500, + skip: commits.length, + })) as CommitEntry[]; + qc.setQueryData(qk.commits(gitRepoPath), (prev) => [ + ...(prev ?? []), + ...more, + ]); + }} + onSelect={(selected) => { + const nextSelectionKey = selected.map((c) => c.hash).join(","); + const currentSelectionKey = selectedCommits + .map((c) => c.hash) + .join(","); + if (nextSelectionKey === currentSelectionKey) { + return; + } + if (selected.length === 1 && selected[0]) { + onSelectCommit(selected[0]); + } else if (selected.length === 2 && selected[0] && selected[1]) { + onSelectCommitRange(selected[0], selected[1]); + } else { + onClearCommitSelection(); + } + }} + onCreateWorktree={() => { + onCreateWorktree(); + }} + onCheckout={(ref) => { + if (activeWorktree) { + void api + .checkout(activeWorktree.path, ref) + .then(() => { + toast("Checked out", "success"); + void qc.invalidateQueries({ + queryKey: qk.commits(gitRepoPath), + }); + void qc.invalidateQueries({ queryKey: qk.refs(gitRepoPath) }); + }) + .catch((err: unknown) => toast(String(err), "error")); + } + }} + onReset={(ref, mode) => { + if (activeWorktree) { + void api + .reset(activeWorktree.path, ref, mode) + .then(() => { + toast(`Reset (${mode}) complete`, "success"); + void qc.invalidateQueries({ + queryKey: qk.commits(gitRepoPath), + }); + void qc.invalidateQueries({ queryKey: qk.refs(gitRepoPath) }); + }) + .catch((err: unknown) => toast(String(err), "error")); + } + }} + issueTrackerPatterns={issueTrackerPatterns} + /> +
+ {selectedCommit && ( + + )} +
+ ); +} diff --git a/app/src/renderer/workspace/TerminalTabPanel.tsx b/app/src/renderer/workspace/TerminalTabPanel.tsx new file mode 100644 index 0000000..b4c7932 --- /dev/null +++ b/app/src/renderer/workspace/TerminalTabPanel.tsx @@ -0,0 +1,230 @@ +import { useState, type CSSProperties, type MutableRefObject } from "react"; +import { TerminalPane } from "@sproutgit/ui"; +import { + Plus, + ChevronDown, + Rows3, + Columns2, + LayoutGrid, + X, +} from "lucide-react"; +import { api } from "../api.js"; +import { + useWorkspaceStore, + type TerminalLayout, +} from "../stores/workspace-store.js"; + +type TerminalSession = ReturnType< + typeof useWorkspaceStore.getState +>["terminalSessions"][number]; + +type Props = { + workspacePath: string; + activeWorktreePath: string | undefined; + visibleSessions: TerminalSession[]; + activeTerminalId: string | null; + terminalLayout: TerminalLayout; + availableShells: { name: string; path: string }[]; + renamingTerminalId: string | null; + renameValue: string; + setRenameValue: (v: string) => void; + renameInputRef: MutableRefObject; + onOpenTerminal: (cwd: string, label?: string, shellOverride?: string) => void; + onCloseTerminal: (id: string) => void; + onOpenTerminalTabMenu: (e: React.MouseEvent, sessionId: string) => void; + onSelectTerminal: (id: string) => void; + onCommitRename: () => void; + onCancelRename: () => void; + onSetLayout: (layout: TerminalLayout) => void; + terminalPanelStyle: (id: string) => CSSProperties; + terminalWrapperClass: () => string; +}; + +/** Terminal tab: session tab bar (rename/menu/shell picker) plus the PTY panes. */ +export function TerminalTabPanel({ + workspacePath, + activeWorktreePath, + visibleSessions, + activeTerminalId, + terminalLayout, + availableShells, + renamingTerminalId, + renameValue, + setRenameValue, + renameInputRef, + onOpenTerminal, + onCloseTerminal, + onOpenTerminalTabMenu, + onSelectTerminal, + onCommitRename, + onCancelRename, + onSetLayout, + terminalPanelStyle, + terminalWrapperClass, +}: Props) { + const [showShellPicker, setShowShellPicker] = useState(false); + + return ( +
+
+
+ {visibleSessions.map((s) => { + const isActive = s.id === activeTerminalId; + const isRenaming = s.id === renamingTerminalId; + return ( +
onOpenTerminalTabMenu(e, s.id)} + > + {isActive && ( + + )} + {isRenaming ? ( + setRenameValue(e.target.value)} + onBlur={onCommitRename} + onKeyDown={(e) => { + if (e.key === "Enter") onCommitRename(); + if (e.key === "Escape") onCancelRename(); + e.stopPropagation(); + }} + onClick={(e) => e.stopPropagation()} + className="min-w-0 w-25 rounded bg-(--sg-input-bg) px-2 py-1 text-[11px] font-medium text-(--sg-text) outline-(--sg-primary)" + /> + ) : ( + + )} + +
+ ); + })} +
+
+ + {availableShells.length > 0 && ( + + )} + {showShellPicker && availableShells.length > 0 && ( + <> +
setShowShellPicker(false)} + /> +
+ {availableShells.map((shell) => ( + + ))} +
+ + )} +
+
+
+ + + +
+
+
+ {visibleSessions.map((s) => ( +
+ { + void api.writeTerminal(id, data).catch(() => undefined); + }} + onResize={(id, cols, rows) => { + void api.resizeTerminal(id, cols, rows).catch(() => undefined); + }} + /> +
+ ))} +
+
+ ); +} diff --git a/app/src/renderer/workspace/WorkspaceDialogs.tsx b/app/src/renderer/workspace/WorkspaceDialogs.tsx new file mode 100644 index 0000000..e109436 --- /dev/null +++ b/app/src/renderer/workspace/WorkspaceDialogs.tsx @@ -0,0 +1,151 @@ +import { WorkspaceHooksModal } from "@sproutgit/ui"; +import { api } from "../api.js"; +import { NewWorktreeDialog } from "./dialogs/NewWorktreeDialog.js"; +import { DeleteWorktreeDialog } from "./dialogs/DeleteWorktreeDialog.js"; +import { PublishDialog } from "./dialogs/PublishDialog.js"; +import { RunHookDialog } from "./dialogs/RunHookDialog.js"; +import { CreatePrDialog } from "./dialogs/CreatePrDialog.js"; +import type { + RefInfo, + WorktreeInfo, + WorktreePushStatus, + IssueTrackerPattern, +} from "@sproutgit/types"; +import type { ToastFn } from "../toast-context.js"; + +type Props = { + workspacePath: string; + activeWorktree: WorktreeInfo | null; + gitRepoPath: string; + defaultShell: string; + refs: RefInfo[]; + issueTrackerPatterns: IssueTrackerPattern[]; + pushStatus: WorktreePushStatus | null | undefined; + managedWorktreesPath: string | undefined; + toast: ToastFn; + + hooksModalOpen: boolean; + onCloseHooksModal: () => void; + + showNewWorktree: boolean; + onCloseNewWorktree: () => void; + onWorktreeCreated: (newWorktreePath: string) => void; + + showPublishModal: boolean; + onClosePublishModal: () => void; + onPublished: () => void; + + createPrTarget: WorktreeInfo | null; + onCloseCreatePr: () => void; + onPrCreated: () => void; + + runHookTarget: WorktreeInfo | null; + onCloseRunHookModal: () => void; + + deleteTarget: WorktreeInfo | null; + deleteLoading: boolean; + onConfirmDelete: (wt: WorktreeInfo) => void; + onCancelDelete: () => void; +}; + +/** Bundles the workspace's modal dialogs (hooks, new/delete worktree, publish, run-hook). */ +export function WorkspaceDialogs({ + workspacePath, + activeWorktree, + gitRepoPath, + defaultShell, + refs, + issueTrackerPatterns, + pushStatus, + managedWorktreesPath, + toast, + hooksModalOpen, + onCloseHooksModal, + showNewWorktree, + onCloseNewWorktree, + onWorktreeCreated, + showPublishModal, + onClosePublishModal, + onPublished, + createPrTarget, + onCloseCreatePr, + onPrCreated, + runHookTarget, + onCloseRunHookModal, + deleteTarget, + deleteLoading, + onConfirmDelete, + onCancelDelete, +}: Props) { + return ( + <> + {/* Hooks settings modal */} + api.listHooks(p, wt), + createHook: (args) => api.createHook(args), + updateHook: (args) => api.updateHook(args), + deleteHook: (p, id) => api.deleteHook(p, id), + toggleHook: (p, id, enabled) => api.toggleHook(p, id, enabled), + trustHook: (wt, hookId) => api.trustHook(wt, hookId), + }} + /> + + {/* New worktree dialog */} + toast(msg, v)} + /> + + {/* Publish branch dialog */} + toast(msg, v)} + onPublished={onPublished} + /> + + {/* Create PR dialog */} + toast(msg, v)} + onCreated={onPrCreated} + /> + + {/* Run hook dialog */} + toast(msg, v)} + /> + + {/* Delete worktree dialog */} + + + ); +} diff --git a/app/src/renderer/workspace/WorkspaceHeader.tsx b/app/src/renderer/workspace/WorkspaceHeader.tsx new file mode 100644 index 0000000..694833e --- /dev/null +++ b/app/src/renderer/workspace/WorkspaceHeader.tsx @@ -0,0 +1,160 @@ +import type { CSSProperties } from "react"; +import { useNavigate } from "@tanstack/react-router"; +import { + GitBranch, + ChevronRight, + ChevronDown, + Settings, + AppWindow, + FolderOpen, + Radar, +} from "lucide-react"; +import { + WindowControls, + UpdateBadge, + useContextMenu, + type UpdateState, +} from "@sproutgit/ui"; +import { api } from "../api.js"; +import type { RecentWorkspace, WorktreeInfo } from "@sproutgit/types"; + +type Props = { + workspacePath: string; + activeWorktree: WorktreeInfo | null; + updateState: UpdateState; + loadRecentWorkspaces: () => Promise; + onSwitchWorkspace: (path: string) => void; + /** Whether any worktree currently has a live agent-launched terminal session — shows a live dot on the Agent Sessions button. */ + hasLiveAgentSession: boolean; + onToggleSessionsPanel: () => void; +}; + +/** Last path segment, tolerating both '/' (macOS/Linux) and '\' (Windows) separators. */ +function workspaceBaseName(p: string): string { + const trimmed = p.trim().replace(/[\\/]+$/g, ""); + const idx = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf("\\")); + return idx === -1 ? trimmed : trimmed.slice(idx + 1); +} + +/** Full-width top header: back/workspace-switcher chrome, active branch, window/settings buttons. */ +export function WorkspaceHeader({ + workspacePath, + activeWorktree, + updateState, + loadRecentWorkspaces, + onSwitchWorkspace, + hasLiveAgentSession, + onToggleSessionsPanel, +}: Props) { + const navigate = useNavigate(); + const contextMenu = useContextMenu(); + + return ( +
+ +
+ +
+ + + + + {activeWorktree?.branch ?? + (activeWorktree?.detached ? "detached" : "—")} + +
+
+ void api.installUpdate()} + /> + + + +
+ +
+ ); +} diff --git a/app/src/renderer/workspace/WorktreeSidebar.tsx b/app/src/renderer/workspace/WorktreeSidebar.tsx index b738cb9..c67b5aa 100644 --- a/app/src/renderer/workspace/WorktreeSidebar.tsx +++ b/app/src/renderer/workspace/WorktreeSidebar.tsx @@ -1,16 +1,230 @@ -import { api } from '../api.js'; -import { useToast } from '../toast-context.js'; -import { describeFetchSummary } from '../queries.js'; -import { useEffect, useState, type ReactNode } from 'react'; +import { api } from "../api.js"; +import { useToast, type ToastFn } from "../toast-context.js"; +import { describeFetchSummary } from "../queries.js"; +import { useEffect, useState, type ReactNode } from "react"; import { ResizableSidebar, Spinner, useContextMenu, UpdateBadge, -} from '@sproutgit/ui'; -import { GitBranch, RefreshCw, ArrowDownToLine, ArrowUpFromLine, Download, Plus, Sliders, Trash2, MoreHorizontal, FolderPen, FolderSearch, SquareTerminal, Play, Copy, CopyPlus, Bot, Link2, Rocket, PanelLeftClose, PanelLeftOpen, GitPullRequest, GitPullRequestDraft, GitPullRequestClosed, GitPullRequestCreate, GitMerge, CircleCheck, CircleX, CircleDashed } from 'lucide-react'; -import type { WorktreeInfo, WorkspaceStatus, PullRequestStatus } from '@sproutgit/types'; -import type { UpdateState } from '@sproutgit/ui'; +} from "@sproutgit/ui"; +import { + GitBranch, + RefreshCw, + ArrowDownToLine, + ArrowUpFromLine, + Download, + Plus, + Sliders, + Trash2, + MoreHorizontal, + FolderPen, + FolderSearch, + SquareTerminal, + Play, + Copy, + CopyPlus, + Bot, + Link2, + Rocket, + PanelLeftClose, + PanelLeftOpen, + GitPullRequest, + GitPullRequestDraft, + GitPullRequestClosed, + GitPullRequestCreate, + GitMerge, + CircleCheck, + CircleX, + CircleDashed, +} from "lucide-react"; +import type { + WorktreeInfo, + WorkspaceStatus, + PullRequestStatus, +} from "@sproutgit/types"; +import type { UpdateState } from "@sproutgit/ui"; + +type MenuItems = Parameters["open"]>[1]; + +/** Items shared by the row's right-click menu and its "..." quick-actions menu. */ +function commonWorktreeMenuItems( + wt: WorktreeInfo, + toast: ToastFn, + agentItems: MenuItems, + prItems: MenuItems, + onOpenTerminal: Props["onOpenTerminal"], +): MenuItems { + return [ + { + label: "Open in Editor", + icon: , + onClick: () => + void api + .openInEditor(wt.path) + .then(() => toast("Opened in editor", "success")) + .catch((err: unknown) => toast(String(err), "error")), + }, + { + label: /mac/i.test(navigator.platform) + ? "Reveal in Finder" + : "Reveal in Explorer", + icon: , + onClick: () => + void api + .revealInFinder(wt.path) + .catch((err: unknown) => toast(String(err), "error")), + }, + { + label: "Open Terminal Here", + icon: , + onClick: () => + onOpenTerminal(wt.path, wt.branch ?? wt.path.split("/").pop()), + }, + ...(agentItems.length > 0 ? ["separator" as const, ...agentItems] : []), + ...(prItems.length > 0 ? ["separator" as const, ...prItems] : []), + ]; +} + +/** Full context menu for a right-clicked worktree row. */ +function buildRowContextMenuItems( + wt: WorktreeInfo, + toast: ToastFn, + agentItems: MenuItems, + prItems: MenuItems, + onOpenTerminal: Props["onOpenTerminal"], + onRefresh: Props["onRefresh"], + onOpenRunHookModal: Props["onOpenRunHookModal"], + onRunCreateHooks: Props["onRunCreateHooks"], + onDeleteWorktree: Props["onDeleteWorktree"], +): MenuItems { + return [ + ...commonWorktreeMenuItems(wt, toast, agentItems, prItems, onOpenTerminal), + "separator", + { + label: "Fetch", + icon: , + onClick: () => + void api + .fetch(wt.path) + .then((summary) => { + toast( + describeFetchSummary(summary), + summary.hadNoRemotes ? "info" : "success", + ); + // Nothing to refresh when there was nothing to fetch (no remotes) or + // nothing changed — matches useFetch's no-op check in queries.ts, avoiding + // a pointless refetch on every click of a no-op fetch. + if ( + summary.hadNoRemotes || + (summary.updatedRefCount === 0 && summary.prunedRefCount === 0) + ) + return; + onRefresh(); + }) + .catch((err: unknown) => + toast(`Fetch failed: ${String(err)}`, "error"), + ), + }, + { + label: "Pull", + icon: , + onClick: () => + void api + .pull(wt.path) + .then(() => { + toast("Pulled", "success"); + onRefresh(); + }) + .catch((err: unknown) => toast(String(err), "error")), + }, + { + label: "Push", + icon: , + onClick: () => + void api + .push(wt.path) + .then(() => { + toast("Pushed", "success"); + }) + .catch((err: unknown) => toast(String(err), "error")), + }, + "separator", + { + label: "Run Hook…", + icon: , + onClick: () => onOpenRunHookModal(wt), + }, + ...(wt.isExternal + ? [ + { + label: "Run Create Hooks…", + icon: , + onClick: () => onRunCreateHooks(wt), + }, + ] + : []), + "separator", + { + label: "Copy Branch Name", + icon: , + onClick: () => void navigator.clipboard.writeText(wt.branch ?? ""), + }, + { + label: "Copy Path", + icon: , + onClick: () => void navigator.clipboard.writeText(wt.path), + }, + "separator", + { + label: "Remove Worktree", + icon: , + danger: true, + onClick: () => onDeleteWorktree(wt), + }, + ]; +} + +/** Compact "..." quick-actions menu — same as the row context menu minus Fetch/Pull/Push and Remove (those have dedicated buttons). */ +function buildQuickActionsMenuItems( + wt: WorktreeInfo, + toast: ToastFn, + agentItems: MenuItems, + prItems: MenuItems, + onOpenTerminal: Props["onOpenTerminal"], + onOpenRunHookModal: Props["onOpenRunHookModal"], + onRunCreateHooks: Props["onRunCreateHooks"], +): MenuItems { + return [ + ...commonWorktreeMenuItems(wt, toast, agentItems, prItems, onOpenTerminal), + "separator", + { + label: "Run Hook…", + icon: , + onClick: () => onOpenRunHookModal(wt), + }, + ...(wt.isExternal + ? [ + { + label: "Run Create Hooks…", + icon: , + onClick: () => onRunCreateHooks(wt), + }, + ] + : []), + "separator", + { + label: "Copy Branch Name", + icon: , + onClick: () => void navigator.clipboard.writeText(wt.branch ?? ""), + }, + { + label: "Copy Path", + icon: , + onClick: () => void navigator.clipboard.writeText(wt.path), + }, + ]; +} type Props = { workspacePath: string; @@ -50,14 +264,15 @@ type Props = { onCreatePr: (wt: WorktreeInfo) => void; }; -const iconBtn = 'inline-flex items-center justify-center p-1.5 bg-transparent border-none cursor-pointer text-(--sg-text-faint) rounded-[4px] transition-colors hover:text-(--sg-text) hover:bg-(--sg-surface-raised) disabled:opacity-40 disabled:cursor-not-allowed'; +const iconBtn = + "inline-flex items-center justify-center p-1.5 bg-transparent border-none cursor-pointer text-(--sg-text-faint) rounded-[4px] transition-colors hover:text-(--sg-text) hover:bg-(--sg-surface-raised) disabled:opacity-40 disabled:cursor-not-allowed"; function isPersistentBranch(branch: string | null) { - return /^(main|master|develop|release\/.+)$/.test(branch ?? ''); + return /^(main|master|develop|release\/.+)$/.test(branch ?? ""); } function tildify(p: string, home: string) { - if (home && p.startsWith(home)) return '~' + p.slice(home.length); + if (home && p.startsWith(home)) return "~" + p.slice(home.length); return p; } @@ -107,11 +322,11 @@ function PrBadge({ status }: { status: PullRequestStatus | null | undefined }) { type InventoryRow = { wt: WorktreeInfo; - typeLabel: 'Managed' | 'Persistent' | 'External'; - section: 'managed' | 'persistent' | 'external'; + typeLabel: "Managed" | "Persistent" | "External"; + section: "managed" | "persistent" | "external"; }; -const PENDING_PATH = '__PENDING__'; +const PENDING_PATH = "__PENDING__"; export function WorktreeSidebar({ workspacePath, @@ -147,46 +362,90 @@ export function WorktreeSidebar({ }: Props) { const toast = useToast(); const contextMenu = useContextMenu(); - const [homeDir, setHomeDir] = useState(''); + const [homeDir, setHomeDir] = useState(""); useEffect(() => { - api.getHomeDir().then(setHomeDir).catch(() => {/* ignore */}); + api + .getHomeDir() + .then(setHomeDir) + .catch(() => { + /* ignore */ + }); }, []); function agentMenuItems(worktreePath: string) { if (!agentConfigured) return []; - return [{ - label: 'Launch AI Agent', - icon: , - onClick: () => onLaunchAgent(worktreePath), - }]; + return [ + { + label: "Launch AI Agent", + icon: , + onClick: () => onLaunchAgent(worktreePath), + }, + ]; } function prMenuItems(wt: WorktreeInfo) { if (!githubConnected) return []; const pr = prStatuses[wt.path]?.pullRequest; if (pr) { - return [{ label: 'View PR', icon: , onClick: () => void api.openUrl(pr.url) }]; + return [ + { + label: "View PR", + icon: , + onClick: () => void api.openUrl(pr.url), + }, + ]; } - return [{ label: 'Create PR…', icon: , onClick: () => onCreatePr(wt) }]; + return [ + { + label: "Create PR…", + icon: , + onClick: () => onCreatePr(wt), + }, + ]; } - const rootPath = workspaceStatus?.rootPath ?? ''; - const nonRootWorktrees = worktrees.filter(wt => wt.path !== rootPath); - const underManaged = nonRootWorktrees.filter(wt => !wt.isExternal); - const persistentWorktrees = underManaged.filter(wt => isPersistentBranch(wt.branch)); - const taskWorktrees = underManaged.filter(wt => !isPersistentBranch(wt.branch)); - const externalWorktrees = nonRootWorktrees.filter(wt => wt.isExternal); + const rootPath = workspaceStatus?.rootPath ?? ""; + const nonRootWorktrees = worktrees.filter((wt) => wt.path !== rootPath); + const underManaged = nonRootWorktrees.filter((wt) => !wt.isExternal); + const persistentWorktrees = underManaged.filter((wt) => + isPersistentBranch(wt.branch), + ); + const taskWorktrees = underManaged.filter( + (wt) => !isPersistentBranch(wt.branch), + ); + const externalWorktrees = nonRootWorktrees.filter((wt) => wt.isExternal); // Flat sorted inventory — managed → persistent → external, alpha within section const inventoryRows: InventoryRow[] = []; - if (creatingWorktree && pendingCreationBranch && !taskWorktrees.some(wt => wt.branch === pendingCreationBranch)) { - inventoryRows.push({ wt: { path: PENDING_PATH, branch: pendingCreationBranch, head: null, detached: false, isExternal: false }, typeLabel: 'Managed', section: 'managed' }); + if ( + creatingWorktree && + pendingCreationBranch && + !taskWorktrees.some((wt) => wt.branch === pendingCreationBranch) + ) { + inventoryRows.push({ + wt: { + path: PENDING_PATH, + branch: pendingCreationBranch, + head: null, + detached: false, + isExternal: false, + }, + typeLabel: "Managed", + section: "managed", + }); } - for (const wt of taskWorktrees) inventoryRows.push({ wt, typeLabel: 'Managed', section: 'managed' }); - for (const wt of persistentWorktrees) inventoryRows.push({ wt, typeLabel: 'Persistent', section: 'persistent' }); - for (const wt of externalWorktrees) inventoryRows.push({ wt, typeLabel: 'External', section: 'external' }); - const sectionRank: Record = { managed: 0, persistent: 1, external: 2 }; + for (const wt of taskWorktrees) + inventoryRows.push({ wt, typeLabel: "Managed", section: "managed" }); + for (const wt of persistentWorktrees) + inventoryRows.push({ wt, typeLabel: "Persistent", section: "persistent" }); + for (const wt of externalWorktrees) + inventoryRows.push({ wt, typeLabel: "External", section: "external" }); + const sectionRank: Record = { + managed: 0, + persistent: 1, + external: 2, + }; inventoryRows.sort((a, b) => { const s = sectionRank[a.section]! - sectionRank[b.section]!; if (s !== 0) return s; @@ -195,8 +454,12 @@ export function WorktreeSidebar({ // Show the active worktree summary only when the active worktree is not the root const activeIsRoot = !activeWorktree || activeWorktree.path === rootPath; - const activeIsPersistent = activeWorktree ? isPersistentBranch(activeWorktree.branch) : false; - const activeDirty = activeWorktree ? (worktreeChangeCounts[activeWorktree.path] ?? 0) : 0; + const activeIsPersistent = activeWorktree + ? isPersistentBranch(activeWorktree.branch) + : false; + const activeDirty = activeWorktree + ? (worktreeChangeCounts[activeWorktree.path] ?? 0) + : 0; // Slim icon rail shown when the sidebar is collapsed — keeps worktree // switching and re-expanding available without taking up horizontal space. @@ -221,27 +484,35 @@ export function WorktreeSidebar({
- {inventoryRows.filter(row => row.wt.path !== PENDING_PATH).map(row => { - const isActive = activeWorktree?.path === row.wt.path; - const label = row.wt.branch ?? (row.wt.detached ? 'detached' : row.wt.path.split('/').pop()) ?? row.wt.path; - const changeCount = worktreeChangeCounts[row.wt.path] ?? 0; - return ( - - ); - })} + {inventoryRows + .filter((row) => row.wt.path !== PENDING_PATH) + .map((row) => { + const isActive = activeWorktree?.path === row.wt.path; + const label = + row.wt.branch ?? + (row.wt.detached ? "detached" : row.wt.path.split("/").pop()) ?? + row.wt.path; + const changeCount = worktreeChangeCounts[row.wt.path] ?? 0; + return ( + + ); + })}
); @@ -284,19 +555,43 @@ export function WorktreeSidebar({ New
- - - -
@@ -305,7 +600,10 @@ export function WorktreeSidebar({ {activeWorktree && !activeIsRoot && (
{/* Left accent rail */} -
)} {/* Worktree list */} -
+
{inventoryRows.length === 0 && !creatingWorktree && (
-

No worktrees yet

+

+ No worktrees yet +

- Create a worktree for each branch you want to work on in parallel. + Create a worktree for each branch you want to work on in + parallel.

{/* Update badge */} - {updateState.status !== 'idle' && updateState.status !== 'up-to-date' && ( -
- void api.installUpdate()} - /> -
- )} + {updateState.status !== "idle" && + updateState.status !== "up-to-date" && ( +
+ void api.installUpdate()} + /> +
+ )}
);