From 5f193f69b5b23142dd35cc4eeb8036d080cf9d7a Mon Sep 17 00:00:00 2001 From: Xinyao Date: Tue, 14 Jul 2026 18:29:18 +0800 Subject: [PATCH 01/69] feat(spool): add renderer sharing foundation --- .../components/sidebar/DesktopQuotaRows.tsx | 100 +++++++++ .../components/sidebar/SpoolDesktopRow.tsx | 68 ++++++ .../components/sidebar/SpoolProjectRow.tsx | 38 ++++ .../components/sidebar/SpoolSessionRow.tsx | 46 ++++ .../components/sidebar/SpoolWorktreeRow.tsx | 78 +++++++ .../components/sidebar/spool-sidebar-rows.ts | 201 +++++++++++++++++ src/renderer/src/i18n/locales/en.json | 17 ++ src/renderer/src/i18n/locales/es.json | 17 ++ src/renderer/src/i18n/locales/ja.json | 17 ++ src/renderer/src/i18n/locales/ko.json | 17 ++ src/renderer/src/i18n/locales/zh.json | 17 ++ src/renderer/src/store/index.ts | 4 +- .../src/store/slices/diffComments.test.ts | 4 +- .../store/slices/spool-sharing-selectors.ts | 115 ++++++++++ .../src/store/slices/spool-sharing-types.ts | 47 ++++ .../src/store/slices/spool-sharing.ts | 202 ++++++++++++++++++ .../src/store/slices/store-test-helpers.ts | 4 +- src/renderer/src/store/types.ts | 4 +- 18 files changed, 992 insertions(+), 4 deletions(-) create mode 100644 src/renderer/src/components/sidebar/DesktopQuotaRows.tsx create mode 100644 src/renderer/src/components/sidebar/SpoolDesktopRow.tsx create mode 100644 src/renderer/src/components/sidebar/SpoolProjectRow.tsx create mode 100644 src/renderer/src/components/sidebar/SpoolSessionRow.tsx create mode 100644 src/renderer/src/components/sidebar/SpoolWorktreeRow.tsx create mode 100644 src/renderer/src/components/sidebar/spool-sidebar-rows.ts create mode 100644 src/renderer/src/store/slices/spool-sharing-selectors.ts create mode 100644 src/renderer/src/store/slices/spool-sharing-types.ts create mode 100644 src/renderer/src/store/slices/spool-sharing.ts diff --git a/src/renderer/src/components/sidebar/DesktopQuotaRows.tsx b/src/renderer/src/components/sidebar/DesktopQuotaRows.tsx new file mode 100644 index 00000000000..9c2e9cb562d --- /dev/null +++ b/src/renderer/src/components/sidebar/DesktopQuotaRows.tsx @@ -0,0 +1,100 @@ +import type React from 'react' +import { clampUsedPercent } from '../../../../shared/usage-percentage-display' +import type { SpoolProviderQuota } from '../../../../shared/spool/spool-catalog-contract' +import { translate } from '@/i18n/i18n' +import type { SpoolDesktopQuotaSidebarRow } from './spool-sidebar-rows' + +type QuotaWindow = SpoolProviderQuota['fiveHour'] + +type DesktopQuotaRowsProps = { + row: SpoolDesktopQuotaSidebarRow +} + +function QuotaMeter({ + label, + providerLabel, + window +}: { + label: string + providerLabel: string + window: QuotaWindow +}): React.JSX.Element { + const usedPercent = clampUsedPercent(window?.usedPercent ?? 0) + const ariaLabel = window + ? translate( + 'auto.components.sidebar.DesktopQuotaRows.meterLabel', + '{{value0}}: {{value1}}% used in {{value2}}', + { value0: providerLabel, value1: String(usedPercent), value2: label } + ) + : translate( + 'auto.components.sidebar.DesktopQuotaRows.windowUnavailable', + '{{value0}}: {{value1}} usage unavailable', + { value0: providerLabel, value1: label } + ) + + return ( +
+
+ {label} + {window ? `${usedPercent}%` : '—'} +
+
+
+
+ {!window ? {ariaLabel} : null} +
+ ) +} + +function ProviderQuotaRow({ + provider, + quota +}: { + provider: SpoolProviderQuota['provider'] + quota: SpoolProviderQuota | null +}): React.JSX.Element { + const providerLabel = provider === 'claude' ? 'Claude' : 'Codex' + const unavailable = !quota || quota.status === 'unavailable' + const fiveHourLabel = translate('auto.components.sidebar.DesktopQuotaRows.fiveHour', '5h') + const sevenDayLabel = translate('auto.components.sidebar.DesktopQuotaRows.sevenDay', '7d') + + return ( +
+ + {providerLabel} + + + +
+ ) +} + +export function DesktopQuotaRows({ row }: DesktopQuotaRowsProps): React.JSX.Element { + const claude = row.quota.find((candidate) => candidate.provider === 'claude') ?? null + const codex = row.quota.find((candidate) => candidate.provider === 'codex') ?? null + return ( +
+ + +
+ ) +} diff --git a/src/renderer/src/components/sidebar/SpoolDesktopRow.tsx b/src/renderer/src/components/sidebar/SpoolDesktopRow.tsx new file mode 100644 index 00000000000..77cb8bc0c54 --- /dev/null +++ b/src/renderer/src/components/sidebar/SpoolDesktopRow.tsx @@ -0,0 +1,68 @@ +import type React from 'react' +import { ChevronRight, Monitor } from 'lucide-react' +import { cn } from '@/lib/utils' +import { translate } from '@/i18n/i18n' +import { TruncatedSidebarLabel } from './truncated-sidebar-label' +import type { SpoolDesktopSidebarRow } from './spool-sidebar-rows' + +type SpoolDesktopRowProps = { + row: SpoolDesktopSidebarRow + onToggle: () => void +} + +function getConnectionLabel(status: SpoolDesktopSidebarRow['connectionStatus']): string | null { + switch (status) { + case 'connected': + return null + case 'connecting': + return translate('auto.components.sidebar.SpoolDesktopRow.connecting', 'Connecting…') + case 'disconnected': + return translate('auto.components.sidebar.SpoolDesktopRow.disconnected', 'Disconnected') + } +} + +export function SpoolDesktopRow({ row, onToggle }: SpoolDesktopRowProps): React.JSX.Element { + const hasProjects = row.projectCount > 0 + const connectionLabel = getConnectionLabel(row.connectionStatus) + const content = ( + <> + + {hasProjects ? ( +
) } +function formatQuotaResetAt(timestamp: number | null): string | null { + if (timestamp === null || !Number.isFinite(timestamp)) { + return null + } + const date = new Date(timestamp) + return Number.isNaN(date.getTime()) ? null : quotaResetFormatter.format(date) +} + function ProviderQuotaRow({ provider, quota diff --git a/src/renderer/src/components/sidebar/SpoolSidebarProjectedRow.tsx b/src/renderer/src/components/sidebar/SpoolSidebarProjectedRow.tsx new file mode 100644 index 00000000000..4b0e4d7c184 --- /dev/null +++ b/src/renderer/src/components/sidebar/SpoolSidebarProjectedRow.tsx @@ -0,0 +1,89 @@ +import type React from 'react' +import { useAppStore } from '@/store' +import { translate } from '@/i18n/i18n' +import { SpoolWindowsFirewallNotice } from '@/components/spool/SpoolWindowsFirewallNotice' +import { SpoolAvailabilityNotice } from '@/components/spool/SpoolAvailabilityNotice' +import { DesktopQuotaRows } from './DesktopQuotaRows' +import { SpoolDesktopRow } from './SpoolDesktopRow' +import { SpoolProjectRow } from './SpoolProjectRow' +import { SpoolSessionRow } from './SpoolSessionRow' +import { SpoolWorktreeRow } from './SpoolWorktreeRow' +import type { WorkspaceSidebarProjectedRow } from './workspace-sidebar-row-projection' + +type SpoolProjectedRow = Exclude + +export function SpoolSidebarProjectedRow({ + projected +}: { + projected: SpoolProjectedRow +}): React.JSX.Element { + const setDesktopExpanded = useAppStore((state) => state.setSpoolDesktopExpanded) + const setProjectExpanded = useAppStore((state) => state.setSpoolProjectExpanded) + const setWorktreeExpanded = useAppStore((state) => state.setSpoolWorktreeExpanded) + const setRoute = useAppStore((state) => state.setActiveSpoolWorkspaceRoute) + const setActiveView = useAppStore((state) => state.setActiveView) + + if (projected.kind === 'spool-section') { + return ( +
+ {translate('auto.components.sidebar.SpoolSidebarProjectedRow.heading', 'Spool')} +
+ ) + } + if (projected.kind === 'spool-windows-firewall') { + return + } + if (projected.kind === 'spool-availability') { + return + } + + const row = projected.row + switch (row.type) { + case 'spool-desktop': + return ( + setDesktopExpanded(row.desktopRef, !row.expanded)} + /> + ) + case 'spool-desktop-quota': + return + case 'spool-project': + return ( + setProjectExpanded(row.desktopRef, row.projectRef, !row.expanded)} + /> + ) + case 'spool-worktree': + return ( + setWorktreeExpanded(row.desktopRef, row.worktreeRef, !row.expanded)} + onSelect={() => { + setRoute({ + desktopRef: row.desktopRef, + worktreeRef: row.worktreeRef, + connectionEpoch: row.connectionEpoch + }) + setActiveView('terminal') + }} + /> + ) + case 'spool-session': + return ( + { + setRoute({ + desktopRef: row.desktopRef, + worktreeRef: row.worktreeRef, + sessionRef: row.sessionRef, + connectionEpoch: row.connectionEpoch + }) + setActiveView('terminal') + }} + /> + ) + } +} diff --git a/src/renderer/src/components/sidebar/SpoolWorktreeRow.tsx b/src/renderer/src/components/sidebar/SpoolWorktreeRow.tsx index 4fe031985ea..40b785cd150 100644 --- a/src/renderer/src/components/sidebar/SpoolWorktreeRow.tsx +++ b/src/renderer/src/components/sidebar/SpoolWorktreeRow.tsx @@ -2,6 +2,7 @@ import type React from 'react' import { ChevronRight, GitBranch } from 'lucide-react' import { cn } from '@/lib/utils' import { translate } from '@/i18n/i18n' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { TruncatedSidebarLabel } from './truncated-sidebar-label' import type { SpoolWorktreeSidebarRow } from './spool-sidebar-rows' @@ -21,12 +22,33 @@ function getDisclosureLabel(row: SpoolWorktreeSidebarRow): string { }) } +function getSessionCatalogLabel( + status: SpoolWorktreeSidebarRow['sessionCatalogStatus'] +): string | null { + switch (status) { + case 'loading': + return translate( + 'auto.components.sidebar.SpoolWorktreeRow.loadingSessions', + 'Loading sessions…' + ) + case 'error': + return translate( + 'auto.components.sidebar.SpoolWorktreeRow.sessionsUnavailable', + 'Session list unavailable' + ) + case 'complete': + return null + } +} + export function SpoolWorktreeRow({ row, onToggle, onSelect }: SpoolWorktreeRowProps): React.JSX.Element { const hasSessions = row.sessionCount > 0 + const sessionCatalogLabel = getSessionCatalogLabel(row.sessionCatalogStatus) + const metadata = [row.branch, sessionCatalogLabel].filter(Boolean).join(' · ') return (
{hasSessions ? ( - + + + + + + {getDisclosureLabel(row)} + + ) : ( )} @@ -64,10 +93,18 @@ export function SpoolWorktreeRow({ >
+ { + if (!open) { + setSpoolProjectPublicationTarget(null) + } + }} + /> ) }) @@ -5164,6 +5321,31 @@ const WorktreeList = React.memo(function WorktreeList({ onWorkspaceBoardDragPreviewCancel = NOOP_WORKSPACE_BOARD_DRAG_PREVIEW_CALLBACK }: WorktreeListProps) { // ── Granular selectors (each is a primitive or shallow-stable ref) ── + const spoolSidebarProjectionInput = useAppStore( + useShallow((state) => ({ + desktops: state.spoolRemoteDesktops, + expandedDesktopRefs: state.spoolExpandedDesktopRefs, + expandedProjectRefsByDesktop: state.spoolExpandedProjectRefsByDesktop, + expandedWorktreeRefsByDesktop: state.spoolExpandedWorktreeRefsByDesktop, + activeRoute: state.activeSpoolWorkspaceRoute, + status: state.spoolSharingStatus, + diagnostic: state.spoolSharingDiagnostic + })) + ) + const spoolRows = useMemo( + () => projectSpoolSidebarRows(spoolSidebarProjectionInput), + [spoolSidebarProjectionInput] + ) + const hasSpoolSidebarContent = + spoolRows.length > 0 || + shouldShowSpoolAvailabilityDiagnostic( + spoolSidebarProjectionInput.status, + spoolSidebarProjectionInput.diagnostic + ) || + shouldShowSpoolWindowsFirewallDiagnostic( + spoolSidebarProjectionInput.status, + spoolSidebarProjectionInput.diagnostic + ) const allWorktrees = useAllWorktrees() const repoMap = useRepoMap() const worktreeMap = useWorktreeMap() @@ -6013,7 +6195,11 @@ const WorktreeList = React.memo(function WorktreeList({ // Why: full-page navigation views are not scoped to one worktree, so no // sidebar card should appear selected while one of them is active. const selectedSidebarWorktreeId = - activeView === 'tasks' || activeView === 'activity' ? null : currentSidebarWorktreeId + activeView === 'tasks' || + activeView === 'activity' || + spoolSidebarProjectionInput.activeRoute !== null + ? null + : currentSidebarWorktreeId // Why layout effect instead of effect: the global Cmd/Ctrl+1–9 key handler // can fire immediately after React commits the new grouped/collapsed order. @@ -6705,7 +6891,7 @@ const WorktreeList = React.memo(function WorktreeList({ importedWorktreesByRepo.size === 0 // Why: Project Group headers can render before workspace rows load, but when // active filters hide everything the Clear Filters empty state must win. - if (rows.length === 0 || filtersHideAllRows) { + if ((rows.length === 0 || filtersHideAllRows) && !hasSpoolSidebarContent) { return (
import('./WorktreeMetaDialog')) const RemoveFolderDialog = lazyWithRetry(() => import('./RemoveFolderDialog')) @@ -154,6 +155,7 @@ function Sidebar({ onWorkspaceBoardDragPreviewCancel={cancelWorkspaceBoardDragPreview} /> + {/* Fixed bottom toolbar */} diff --git a/src/renderer/src/components/sidebar/spool-sidebar-rows.ts b/src/renderer/src/components/sidebar/spool-sidebar-rows.ts index 4670e6ecce2..249929c3af0 100644 --- a/src/renderer/src/components/sidebar/spool-sidebar-rows.ts +++ b/src/renderer/src/components/sidebar/spool-sidebar-rows.ts @@ -1,6 +1,7 @@ import type { SpoolProviderQuota, - SpoolRemoteDesktop + SpoolRemoteDesktop, + SpoolWorktreeCatalogEntry } from '../../../../shared/spool/spool-catalog-contract' import type { SpoolExpandedRefsByDesktop, @@ -50,6 +51,7 @@ export type SpoolWorktreeSidebarRow = { expanded: boolean active: boolean sessionCount: number + sessionCatalogStatus: SpoolWorktreeCatalogEntry['sessionCatalog']['status'] } export type SpoolSessionSidebarRow = { @@ -171,7 +173,8 @@ export function projectSpoolSidebarRows(input: SpoolSidebarProjectionInput): Spo branch: worktree.branch, expanded: worktreeExpanded, active: worktreeActive && !input.activeRoute?.sessionRef, - sessionCount: worktree.sessions.length + sessionCount: worktree.sessions.length, + sessionCatalogStatus: worktree.sessionCatalog.status }) if (!worktreeExpanded) { continue diff --git a/src/renderer/src/components/sidebar/workspace-sidebar-row-projection.ts b/src/renderer/src/components/sidebar/workspace-sidebar-row-projection.ts new file mode 100644 index 00000000000..0c112d76ded --- /dev/null +++ b/src/renderer/src/components/sidebar/workspace-sidebar-row-projection.ts @@ -0,0 +1,160 @@ +import { defaultRangeExtractor, type Range } from '@tanstack/react-virtual' +import type { RenderRow } from './worktree-list-virtual-rows' +import { + estimateRenderRowSize, + extractWorktreeVirtualRowIndexes +} from './worktree-list-virtual-rows' +import type { SpoolSidebarRow } from './spool-sidebar-rows' +import { + projectSpoolAvailabilityDiagnostic, + type SpoolAvailabilityDiagnostic +} from '@/components/spool/spool-availability-diagnostic' + +export type WorkspaceSidebarProjectedRow = + | { + kind: 'local' + key: string + localIndex: number + row: RenderRow + } + | { + kind: 'spool-section' + key: 'spool:section' + } + | { + kind: 'spool-windows-firewall' + key: 'spool:windows-firewall' + } + | { + kind: 'spool-availability' + key: 'spool:availability' + diagnostic: SpoolAvailabilityDiagnostic + } + | { + kind: 'spool' + key: string + row: SpoolSidebarRow + } + +export function shouldShowSpoolWindowsFirewallDiagnostic( + status: 'starting' | 'ready' | 'unavailable', + diagnostic: string | null +): boolean { + return status === 'unavailable' && diagnostic === 'spool_windows_firewall_unavailable' +} + +export function shouldShowSpoolAvailabilityDiagnostic( + status: 'starting' | 'ready' | 'unavailable', + diagnostic: string | null +): boolean { + return projectSpoolAvailabilityDiagnostic(status, diagnostic) !== null +} + +export function projectWorkspaceSidebarRows(args: { + localRows: readonly RenderRow[] + spoolRows: readonly SpoolSidebarRow[] + spoolStatus: 'starting' | 'ready' | 'unavailable' + spoolDiagnostic: string | null + getLocalRowKey: (row: RenderRow) => string +}): WorkspaceSidebarProjectedRow[] { + const rows: WorkspaceSidebarProjectedRow[] = args.localRows.map((row, localIndex) => ({ + kind: 'local', + key: args.getLocalRowKey(row), + localIndex, + row + })) + const showWindowsFirewall = shouldShowSpoolWindowsFirewallDiagnostic( + args.spoolStatus, + args.spoolDiagnostic + ) + const availabilityDiagnostic = projectSpoolAvailabilityDiagnostic( + args.spoolStatus, + args.spoolDiagnostic + ) + if (args.spoolRows.length === 0 && !showWindowsFirewall && !availabilityDiagnostic) { + return rows + } + // Why: Spool resources are a separate remote namespace; placing their + // section after local rows avoids interleaving opaque refs with local hosts. + rows.push({ kind: 'spool-section', key: 'spool:section' }) + if (showWindowsFirewall) { + rows.push({ kind: 'spool-windows-firewall', key: 'spool:windows-firewall' }) + } else if (availabilityDiagnostic) { + rows.push({ + kind: 'spool-availability', + key: 'spool:availability', + diagnostic: availabilityDiagnostic + }) + } + rows.push(...args.spoolRows.map((row) => ({ kind: 'spool' as const, key: row.key, row }))) + return rows +} + +export function getWorkspaceSidebarRowKey(row: WorkspaceSidebarProjectedRow): string { + return row.key +} + +export function estimateWorkspaceSidebarRowSize(args: { + rows: readonly WorkspaceSidebarProjectedRow[] + localRows: readonly RenderRow[] + index: number + firstLocalHeaderIndex: number + activeStickyHeaderIndex: number | null +}): number { + const projected = args.rows[args.index] + if (!projected) { + return 32 + } + if (projected.kind === 'local') { + return estimateRenderRowSize( + args.localRows, + projected.localIndex, + args.firstLocalHeaderIndex, + args.activeStickyHeaderIndex + ) + } + if (projected.kind === 'spool-windows-firewall') { + return 154 + } + if (projected.kind === 'spool-availability') { + return 144 + } + if (projected.kind === 'spool-section' || projected.row.type === 'spool-project') { + return 28 + } + if (projected.row.type === 'spool-desktop') { + return 42 + } + if (projected.row.type === 'spool-desktop-quota') { + return 76 + } + if (projected.row.type === 'spool-worktree') { + return 38 + } + return 28 +} + +export function extractWorkspaceSidebarVirtualRowIndexes(args: { + range: Range + localRowCount: number + localRows: readonly RenderRow[] + stickyHeaderIndexes: readonly number[] +}): number[] { + // Why: local sticky headers describe local projects only; once the viewport + // enters Spool, keeping the last local header pinned misstates ownership. + if (args.range.startIndex >= args.localRowCount) { + return defaultRangeExtractor(args.range) + } + return extractWorktreeVirtualRowIndexes({ + range: args.range, + stickyHeaderIndexes: args.stickyHeaderIndexes, + rows: args.localRows + }) +} + +export function workspaceSidebarStickyRangeStart( + rangeStartIndex: number, + localRowCount: number +): number | null { + return rangeStartIndex < localRowCount ? rangeStartIndex : null +} diff --git a/src/renderer/src/components/spool/SpoolAvailabilityNotice.tsx b/src/renderer/src/components/spool/SpoolAvailabilityNotice.tsx new file mode 100644 index 00000000000..7d0e7714e9f --- /dev/null +++ b/src/renderer/src/components/spool/SpoolAvailabilityNotice.tsx @@ -0,0 +1,126 @@ +import { useRef, useState } from 'react' +import { CircleAlert, Loader2, RefreshCw } from 'lucide-react' +import { SPOOL_INGRESS_PORT } from '../../../../shared/spool/spool-wire-contract' +import { Button } from '@/components/ui/button' +import { useMountedRef } from '@/hooks/useMountedRef' +import { translate } from '@/i18n/i18n' +import type { SpoolAvailabilityDiagnostic } from './spool-availability-diagnostic' + +export function SpoolAvailabilityNotice({ + diagnostic +}: { + diagnostic: SpoolAvailabilityDiagnostic +}): React.JSX.Element { + const [retrying, setRetrying] = useState(false) + const retryInFlightRef = useRef(false) + const mountedRef = useMountedRef() + + async function retryAvailability(): Promise { + if (retryInFlightRef.current) { + return + } + retryInFlightRef.current = true + setRetrying(true) + try { + await window.api.spoolSharing.retryAvailability() + } catch { + // Why: the sanitized availability notice remains the recovery feedback; + // Electron transport errors may contain host details that must not surface. + } finally { + retryInFlightRef.current = false + if (mountedRef.current) { + setRetrying(false) + } + } + } + + return ( +
+
+
+
+
+
+ ) +} + +function getAvailabilityDescription(diagnostic: SpoolAvailabilityDiagnostic): string { + switch (diagnostic) { + case 'tailscale_unavailable': + return translate( + 'auto.components.spool.SpoolAvailabilityNotice.tailscaleUnavailable', + 'Tailscale could not be found on this desktop.' + ) + case 'tailscale_not-running': + return translate( + 'auto.components.spool.SpoolAvailabilityNotice.tailscaleNotRunning', + 'Tailscale is installed, but its service is not running.' + ) + case 'tailscale_permission-denied': + return translate( + 'auto.components.spool.SpoolAvailabilityNotice.tailscalePermissionDenied', + 'Orca does not have permission to read Tailscale status.' + ) + case 'tailscale_timed-out': + return translate( + 'auto.components.spool.SpoolAvailabilityNotice.tailscaleTimedOut', + 'Tailscale did not respond in time. Spool will keep checking.' + ) + case 'tailscale_unsupported-output': + return translate( + 'auto.components.spool.SpoolAvailabilityNotice.tailscaleUnsupportedOutput', + 'Tailscale returned status data that this Orca version cannot read.' + ) + case 'spool_port_unavailable': + return translate( + 'auto.components.spool.SpoolAvailabilityNotice.portUnavailable', + 'TCP port {{port}} is already in use. Close the conflicting process; Spool will keep checking.', + { port: SPOOL_INGRESS_PORT } + ) + case 'spool_permission_denied': + return translate( + 'auto.components.spool.SpoolAvailabilityNotice.permissionDenied', + 'System permissions prevented Orca from opening the Spool Tailnet listener.' + ) + case 'persistence_unavailable': + return translate( + 'auto.components.spool.SpoolAvailabilityNotice.persistenceUnavailable', + 'Spool could not safely load sharing settings, so sharing remains off.' + ) + case 'spool_unavailable': + return translate( + 'auto.components.spool.SpoolAvailabilityNotice.unavailable', + 'Spool could not start on this desktop.' + ) + } +} diff --git a/src/renderer/src/components/spool/SpoolControlRequestDialog.tsx b/src/renderer/src/components/spool/SpoolControlRequestDialog.tsx new file mode 100644 index 00000000000..1b61772f30f --- /dev/null +++ b/src/renderer/src/components/spool/SpoolControlRequestDialog.tsx @@ -0,0 +1,141 @@ +import type React from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' +import { AlertTriangle } from 'lucide-react' +import { toast } from 'sonner' +import { useAppStore } from '@/store' +import { translate } from '@/i18n/i18n' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from '@/components/ui/dialog' + +export function SpoolControlRequestDialog(): React.JSX.Element | null { + const request = useAppStore((state) => state.spoolControlRequestQueue[0] ?? null) + const requestCount = useAppStore((state) => state.spoolControlRequestQueue.length) + const removeRequest = useAppStore((state) => state.removeSpoolControlRequest) + const denyButtonRef = useRef(null) + const [decidingRequestId, setDecidingRequestId] = useState(null) + + useEffect(() => { + if (decidingRequestId && request?.requestId !== decidingRequestId) { + setDecidingRequestId(null) + } + }, [decidingRequestId, request?.requestId]) + + const decide = useCallback( + async (decision: 'allow' | 'deny'): Promise => { + if (!request || decidingRequestId) { + return + } + setDecidingRequestId(request.requestId) + try { + await window.api.spoolSharing.decideControl({ requestId: request.requestId, decision }) + removeRequest(request.requestId) + } catch { + toast.error( + translate( + 'auto.components.spool.SpoolControlRequestDialog.decisionFailed', + 'Could not send the control decision.' + ) + ) + setDecidingRequestId(null) + } + }, + [decidingRequestId, removeRequest, request] + ) + + if (!request) { + return null + } + + const requesterLabel = `${request.requester.userDisplayName} · ${request.requester.nodeDisplayName}` + const isDeciding = decidingRequestId === request.requestId + + return ( + + event.preventDefault()} + onPointerDownOutside={(event) => event.preventDefault()} + onOpenAutoFocus={(event) => { + event.preventDefault() + // Why: approval exposes a real remote shell; Enter must never accept + // merely because the dialog appeared while the user was typing. + denyButtonRef.current?.focus() + }} + > + + + {translate( + 'auto.components.spool.SpoolControlRequestDialog.title', + 'Allow {{value0}} to control this worktree?', + { value0: requesterLabel } + )} + + + {translate( + 'auto.components.spool.SpoolControlRequestDialog.worktree', + 'Worktree: {{value0}}', + { value0: request.worktreeDisplayName } + )} + + + +
+
+ + {requestCount > 1 ? ( +

+ {translate( + 'auto.components.spool.SpoolControlRequestDialog.queued', + '{{value0}} more control request(s) are queued.', + { value0: requestCount - 1 } + )} +

+ ) : null} + + + + + +
+
+ ) +} diff --git a/src/renderer/src/components/spool/SpoolFileActionDialog.tsx b/src/renderer/src/components/spool/SpoolFileActionDialog.tsx new file mode 100644 index 00000000000..c1a9a6ee9a9 --- /dev/null +++ b/src/renderer/src/components/spool/SpoolFileActionDialog.tsx @@ -0,0 +1,131 @@ +import type React from 'react' +import { useEffect, useState } from 'react' +import type { SpoolFileTreeEntry } from '../../../../shared/spool/spool-operation-contract' +import { translate } from '@/i18n/i18n' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from '@/components/ui/dialog' +import { Input } from '@/components/ui/input' + +export type SpoolFileAction = + | { kind: 'new-file' } + | { kind: 'new-directory' } + | { kind: 'rename'; entry: SpoolFileTreeEntry } + | { kind: 'delete'; entry: SpoolFileTreeEntry } + +export function SpoolFileActionDialog({ + action, + busy, + onClose, + onSubmit +}: { + action: SpoolFileAction | null + busy: boolean + onClose: () => void + onSubmit: (value: string) => Promise +}): React.JSX.Element { + const [value, setValue] = useState('') + useEffect(() => { + setValue(action?.kind === 'rename' ? action.entry.name : '') + }, [action]) + + const needsName = + action?.kind === 'new-file' || action?.kind === 'new-directory' || action?.kind === 'rename' + const copy = getActionCopy(action) + + return ( + !open && !busy && onClose()}> + + + {copy.title} + {copy.description} + + {needsName ? ( + setValue(event.currentTarget.value)} + onKeyDown={(event) => { + if (event.key === 'Enter' && value.trim()) { + void onSubmit(value.trim()) + } + }} + /> + ) : null} + + + + + + + ) +} + +function getActionCopy(action: SpoolFileAction | null): { + title: string + description: string + label: string +} { + switch (action?.kind) { + case 'new-file': + return { + title: translate('auto.components.spool.SpoolFileActionDialog.newFileTitle', 'New file'), + description: translate( + 'auto.components.spool.SpoolFileActionDialog.newFileDescription', + 'Create an empty file in the current remote folder.' + ), + label: translate('auto.components.spool.SpoolFileActionDialog.create', 'Create') + } + case 'new-directory': + return { + title: translate( + 'auto.components.spool.SpoolFileActionDialog.newDirectoryTitle', + 'New directory' + ), + description: translate( + 'auto.components.spool.SpoolFileActionDialog.newDirectoryDescription', + 'Create a directory in the current remote folder.' + ), + label: translate('auto.components.spool.SpoolFileActionDialog.create', 'Create') + } + case 'rename': + return { + title: translate('auto.components.spool.SpoolFileActionDialog.renameTitle', 'Rename item'), + description: translate( + 'auto.components.spool.SpoolFileActionDialog.renameDescription', + 'Enter a new name for {{value0}}.', + { value0: action.entry.name } + ), + label: translate('auto.components.spool.SpoolFileActionDialog.rename', 'Rename') + } + case 'delete': + return { + title: translate('auto.components.spool.SpoolFileActionDialog.deleteTitle', 'Delete item?'), + description: translate( + 'auto.components.spool.SpoolFileActionDialog.deleteDescription', + 'This permanently deletes {{value0}} from the owner’s worktree.', + { value0: action.entry.name } + ), + label: translate('auto.components.spool.SpoolFileActionDialog.delete', 'Delete') + } + case undefined: + return { title: '', description: '', label: '' } + } +} diff --git a/src/renderer/src/components/spool/SpoolFilePreview.tsx b/src/renderer/src/components/spool/SpoolFilePreview.tsx new file mode 100644 index 00000000000..04cfacbe2d3 --- /dev/null +++ b/src/renderer/src/components/spool/SpoolFilePreview.tsx @@ -0,0 +1,313 @@ +import type React from 'react' +import { useEffect, useState } from 'react' +import { FileDiff, RefreshCw, Save, Trash2 } from 'lucide-react' +import type { + SpoolFileDiffResult, + SpoolFileReadResult, + SpoolFileTreeEntry +} from '../../../../shared/spool/spool-operation-contract' +import { translate } from '@/i18n/i18n' +import { Button } from '@/components/ui/button' +import { cn } from '@/lib/utils' + +type FilePreviewMode = 'content' | 'working-diff' | 'staged-diff' + +export function SpoolFilePreview({ + canControl, + draft, + file, + fileEntry, + fileUnavailable, + loading, + saving, + diff, + diffLoading, + diffUnavailable, + onDelete, + onDraftChange, + onLoadDiff, + onRefresh, + onRename, + onSave +}: { + canControl: boolean + draft: string + file: SpoolFileReadResult | null + fileEntry: SpoolFileTreeEntry | null + fileUnavailable: boolean + loading: boolean + saving: boolean + diff: SpoolFileDiffResult | null + diffLoading: boolean + diffUnavailable: boolean + onDelete: () => void + onDraftChange: (value: string) => void + onLoadDiff: (staged: boolean) => void + onRefresh: () => void + onRename: () => void + onSave: () => void +}): React.JSX.Element { + const [mode, setMode] = useState('content') + useEffect(() => setMode('content'), [file?.relativePath]) + + if (loading) { + return ( + + ) + } + if (fileUnavailable) { + return ( + + ) + } + if (!file || !fileEntry) { + return ( + + ) + } + + const dirty = file.encoding === 'utf8' && draft !== file.content + const editable = canControl && file.encoding === 'utf8' && !file.truncated + const showDiff = mode !== 'content' + return ( +
+
+ + {file.relativePath} + + + + + {showDiff ? ( + + ) : null} + + + {!showDiff && file.encoding === 'utf8' ? ( + + ) : null} +
+ {showDiff ? ( + + ) : file.encoding === 'utf8' ? ( + + ) : ( + + )} +
+ ) +} + +function TextProjection({ + draft, + editable, + file, + onDraftChange +}: { + draft: string + editable: boolean + file: SpoolFileReadResult + onDraftChange: (value: string) => void +}): React.JSX.Element { + return ( +
+ {file.truncated ? ( + + ) : null} +