Main no longer opens desktop business SQLite. A single utility-process
storage owner holds the Assistant, Knowledge, attachment, document-result,
remote-binding and native model-ledger databases and runs whole domain
transactions; Main and Runtime callers use explicit async domain operations.
- Remove the Main readWithFallback path and the supervision worker's
separate business write connection; manifests are scanned read-only and
committed by the sole writer.
- Remote events are ACKed only after commit; process loss reports
unconfirmed writes instead of replaying them.
- Use native utilityProcess messaging with one in-flight bound (32) and
ordered waiting instead of capacity rejection and retries.
- Start the storage process before the window so startup overlaps.
- Keep short tool output in memory and store full output once in owned
storage; paging, branching, restart and deletion reuse that copy.
- Put required runtime launch files under goodbuddy-prefixed owned temp
directories and reclaim confirmed stale ones; stop writing duplicate
parsed.md.
- Add lint guards for SQLite ownership and architecture, concurrency
acceptance fixtures, and maintenance/architecture documentation.
Validation: typecheck and lint pass. Alternating baseline (edf019aa) vs
candidate runs show no regression on single-session, C01, C02, C04 and a
5-minute C06; Main p99 drops from ~21 ms to ~16.7 ms under concurrency.
Fresh portable copy (1.75M events) upgrade content unchanged; real-model
output paging passes with 4 external requests. Full npm test runs had
1-9 different timing-sensitive failures per run that pass when rerun.
Not covered: C03 review publication, C07 source body, app-level C05.
1903 lines
64 KiB
TypeScript
1903 lines
64 KiB
TypeScript
import {
|
|
Archive,
|
|
ArrowUp,
|
|
Check,
|
|
ChevronDown,
|
|
CircleAlert,
|
|
Folder,
|
|
FolderOpen,
|
|
LoaderCircle,
|
|
RadioTower,
|
|
RefreshCw,
|
|
Settings,
|
|
Trash2,
|
|
X
|
|
} from 'lucide-react'
|
|
import type { LucideIcon } from 'lucide-react'
|
|
import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, memo } from 'react'
|
|
import { createPortal } from 'react-dom'
|
|
import { useTranslation } from 'react-i18next'
|
|
import type {
|
|
AssistantProject,
|
|
ProjectCreateInput
|
|
} from '../../shared/assistant-contracts'
|
|
import {
|
|
projectChannelLabels
|
|
} from '../../shared/assistant-contracts'
|
|
import type { RuntimeSettings } from '../../shared/contracts'
|
|
import {
|
|
remoteProjectRootPathSchema,
|
|
type RemoteProjectSavePhase
|
|
} from '../../shared/remote-project-candidate-contracts'
|
|
import type {
|
|
SshDirectoryBrowseResult,
|
|
SshHostAgentConnectionState,
|
|
SshHostsSnapshot
|
|
} from '../../shared/ssh-host-contracts'
|
|
import type {
|
|
RemoteProjectRecoveryState
|
|
} from '../../shared/remote-project-recovery-contracts'
|
|
import { activateModalFocus, trapTabFocus } from './dialog-focus'
|
|
import {
|
|
resolveRuntimeChoice,
|
|
type RuntimeSelectionLayer
|
|
} from '../../shared/runtime-selection-contracts'
|
|
import {
|
|
channelProjectDraft,
|
|
ChannelProjectSettingsFields
|
|
} from './ChannelProjectSettingsFields'
|
|
import { getProjectDisplayText } from './project-display'
|
|
import { ProjectRuntimeSelector } from './ProjectRuntimeSelector'
|
|
import { ProjectActivityCounts } from './ProjectActivity'
|
|
import type { ProjectActivityCountsProps } from './ProjectActivity'
|
|
import { ChannelIcon } from './ChannelIcon'
|
|
import { SegmentedControl } from './WorkspacePrimitives'
|
|
import { InlineHelp } from './InlineHelp'
|
|
import { WorkspaceMenu } from './WorkspaceMenu'
|
|
import type { ConversationActivity } from './conversation-activity'
|
|
import type { ConversationStore } from './conversation-store'
|
|
import { displayErrorMessage } from './error-message'
|
|
import type { AppNotificationInput } from './notifications'
|
|
|
|
type ProjectSwitcherProps = {
|
|
projects: AssistantProject[]
|
|
activeProjectId: string
|
|
conversationStore: ConversationStore
|
|
activities: ConversationActivity[]
|
|
visible?: boolean
|
|
onOpenConversation: (conversationId: string) => void
|
|
onNewConversation: (projectId: string) => void
|
|
activityByProjectId?: Record<string, ProjectActivityCountsProps>
|
|
remoteProjectsEnabled?: boolean
|
|
runtimeSettings?: RuntimeSettings
|
|
onArchive: (projectId: string) => Promise<void>
|
|
onRestore: (projectId: string) => Promise<void>
|
|
notify: (input: AppNotificationInput) => void
|
|
onCreate: (input: ProjectCreateInput) => Promise<AssistantProject>
|
|
onDelete: (projectId: string, confirmation: string) => Promise<void>
|
|
onSelect: (projectId: string) => void
|
|
onSelectRoot: () => Promise<string | undefined>
|
|
onRemoteCommitted: (project: AssistantProject) => Promise<void>
|
|
onRetryRecovery?: (projectId: string) => Promise<void>
|
|
onUpdate: (
|
|
projectId: string,
|
|
input: ProjectCreateInput
|
|
) => Promise<AssistantProject>
|
|
recoveryByProjectId?: Readonly<
|
|
Record<string, RemoteProjectRecoveryState>
|
|
>
|
|
}
|
|
|
|
const remoteProjectPhases: readonly RemoteProjectSavePhase[] = [
|
|
'host',
|
|
'agent',
|
|
'workspace',
|
|
'runtime',
|
|
'saving'
|
|
]
|
|
type RemoteHostReadiness =
|
|
| { status: 'ready' }
|
|
| { status: 'unready' }
|
|
|
|
const emptyHosts: SshHostsSnapshot['hosts'] = []
|
|
|
|
function ProjectRecoveryStatus({
|
|
state
|
|
}: {
|
|
state: RemoteProjectRecoveryState
|
|
}): React.JSX.Element {
|
|
const { t } = useTranslation('workspace')
|
|
const working =
|
|
state.stage === 'network' ||
|
|
state.stage === 'agent' ||
|
|
state.stage === 'runtime' ||
|
|
state.stage === 'cursor'
|
|
const text =
|
|
state.stage === 'cursor'
|
|
? t('projectSwitcher.recovery.stages.cursor', {
|
|
current: state.current
|
|
})
|
|
: state.stage === 'failed'
|
|
? t('projectSwitcher.recovery.failed', {
|
|
message: state.message
|
|
})
|
|
: t(`projectSwitcher.recovery.stages.${state.stage}`)
|
|
return (
|
|
<span
|
|
aria-busy={working ? 'true' : undefined}
|
|
aria-live={state.stage === 'failed' ? 'assertive' : 'polite'}
|
|
className={`project-recovery-status project-recovery-status--${state.stage}`}
|
|
role={state.stage === 'failed' ? 'alert' : 'status'}
|
|
>
|
|
{working ? (
|
|
<LoaderCircle aria-hidden="true" size={13} />
|
|
) : state.stage === 'completed' ? (
|
|
<Check aria-hidden="true" size={13} />
|
|
) : (
|
|
<CircleAlert aria-hidden="true" size={13} />
|
|
)}
|
|
<span>{text}</span>
|
|
</span>
|
|
)
|
|
}
|
|
|
|
function RemoteProjectProgress({
|
|
phase,
|
|
phases = remoteProjectPhases,
|
|
title
|
|
}: {
|
|
phase: RemoteProjectSavePhase
|
|
phases?: readonly RemoteProjectSavePhase[]
|
|
title: string
|
|
}): React.JSX.Element {
|
|
const { t } = useTranslation('workspace')
|
|
const phaseIndex = phases.indexOf(phase)
|
|
return (
|
|
<section
|
|
aria-live="polite"
|
|
className="remote-project-progress"
|
|
role="status"
|
|
>
|
|
<div className="remote-project-progress__header">
|
|
<strong>{title}</strong>
|
|
<span>
|
|
{t('projectSwitcher.remote.phaseStatus', {
|
|
phase: t(
|
|
`projectSwitcher.remote.phases.${phase}`
|
|
)
|
|
})}
|
|
</span>
|
|
</div>
|
|
<progress
|
|
aria-label={t(
|
|
'projectSwitcher.remote.progress.progressLabel'
|
|
)}
|
|
max={phases.length}
|
|
value={phaseIndex + 1}
|
|
/>
|
|
<ol
|
|
aria-label={t(
|
|
'projectSwitcher.remote.progress.stepsLabel'
|
|
)}
|
|
>
|
|
{phases.map((candidate, index) => {
|
|
const state =
|
|
index < phaseIndex
|
|
? 'complete'
|
|
: index === phaseIndex
|
|
? 'current'
|
|
: 'pending'
|
|
return (
|
|
<li
|
|
aria-current={
|
|
state === 'current' ? 'step' : undefined
|
|
}
|
|
data-state={state}
|
|
key={candidate}
|
|
>
|
|
<span aria-hidden="true">
|
|
{state === 'complete' ? (
|
|
<Check size={11} />
|
|
) : (
|
|
index + 1
|
|
)}
|
|
</span>
|
|
<small>
|
|
{t(
|
|
`projectSwitcher.remote.phases.${candidate}`
|
|
)}
|
|
</small>
|
|
</li>
|
|
)
|
|
})}
|
|
</ol>
|
|
</section>
|
|
)
|
|
}
|
|
|
|
function isRemoteAbsolutePath(value: string): boolean {
|
|
return remoteProjectRootPathSchema.safeParse(value).success
|
|
}
|
|
|
|
function ProjectSwitcherView({
|
|
projects,
|
|
activeProjectId,
|
|
conversationStore,
|
|
activities,
|
|
visible = true,
|
|
onOpenConversation,
|
|
onNewConversation,
|
|
activityByProjectId = {},
|
|
remoteProjectsEnabled = false,
|
|
runtimeSettings,
|
|
onArchive,
|
|
onRestore,
|
|
notify,
|
|
onCreate,
|
|
onDelete,
|
|
onRemoteCommitted,
|
|
onRetryRecovery,
|
|
onSelect,
|
|
onSelectRoot,
|
|
onUpdate,
|
|
recoveryByProjectId = {}
|
|
}: ProjectSwitcherProps): React.JSX.Element {
|
|
const { t } = useTranslation('workspace')
|
|
useLayoutEffect(() => {
|
|
conversationStore.recordActivityStatuses(activities)
|
|
}, [activities, conversationStore])
|
|
const [dialogMode, setDialogMode] = useState<
|
|
'create' | 'settings'
|
|
>()
|
|
const [settingsProjectId, setSettingsProjectId] =
|
|
useState<string>()
|
|
const [saving, setSaving] = useState(false)
|
|
const [archiving, setArchiving] = useState(false)
|
|
const [deleting, setDeleting] = useState(false)
|
|
const [confirmingDelete, setConfirmingDelete] = useState(false)
|
|
const [deleteConfirmation, setDeleteConfirmation] = useState('')
|
|
const [error, setError] = useState<string>()
|
|
const [executionSpaceKind, setExecutionSpaceKind] = useState<
|
|
'local' | 'ssh'
|
|
>('local')
|
|
const [sshHosts, setSshHosts] = useState<SshHostsSnapshot>()
|
|
const [
|
|
agentConnectionStatusByHostId,
|
|
setAgentConnectionStatusByHostId
|
|
] = useState<Record<string, SshHostAgentConnectionState>>({})
|
|
const agentConnectionStatusRevisionRef = useRef(0)
|
|
const liveAgentConnectionStatusRef = useRef(
|
|
new Map<
|
|
string,
|
|
{
|
|
revision: number
|
|
state: SshHostAgentConnectionState
|
|
}
|
|
>()
|
|
)
|
|
const pendingSshHostsSnapshotsRef = useRef(0)
|
|
const [loadingSshHosts, setLoadingSshHosts] = useState(false)
|
|
const [remoteHostReadiness, setRemoteHostReadiness] = useState<
|
|
Record<string, RemoteHostReadiness>
|
|
>({})
|
|
const [remoteHostId, setRemoteHostId] = useState('')
|
|
const [remoteRootPath, setRemoteRootPath] = useState('')
|
|
const [remoteSaving, setRemoteSaving] = useState(false)
|
|
const [remoteSavePhase, setRemoteSavePhase] =
|
|
useState<RemoteProjectSavePhase>()
|
|
const [directoryPickerOpen, setDirectoryPickerOpen] = useState(false)
|
|
const [directoryPickerLoading, setDirectoryPickerLoading] =
|
|
useState(false)
|
|
const [directoryPickerError, setDirectoryPickerError] =
|
|
useState<string>()
|
|
const [remoteDirectoryListing, setRemoteDirectoryListing] =
|
|
useState<SshDirectoryBrowseResult>()
|
|
const directoryPickerTriggerRef = useRef<HTMLButtonElement>(null)
|
|
const directoryPickerRef = useRef<HTMLDivElement>(null)
|
|
const directoryBrowseRequestRef = useRef(0)
|
|
const projectPickerRef = useRef<HTMLDivElement>(null)
|
|
const projectPickerButtonRef = useRef<HTMLButtonElement>(null)
|
|
const menuId = useId()
|
|
const dialogRef = useRef<HTMLDivElement>(null)
|
|
const restoreFocusTarget = useRef<
|
|
'create' | 'picker' | undefined
|
|
>(undefined)
|
|
const [projectMenuOpen, setProjectMenuOpen] = useState(false)
|
|
if (!visible && projectMenuOpen) setProjectMenuOpen(false)
|
|
const closeProjectMenu = useCallback(() => setProjectMenuOpen(false), [])
|
|
const visibleActivities = useMemo(() => {
|
|
const visibleIds = new Set(projects.filter((project) => project.kind === 'channel' ||
|
|
project.executionSpace.kind !== 'ssh' || remoteProjectsEnabled).map((project) => project.id))
|
|
return activities.filter((activity) => !activity.projectId || visibleIds.has(activity.projectId))
|
|
}, [activities, projects, remoteProjectsEnabled])
|
|
const activityCounts = useMemo(() => {
|
|
const counts = { attention: 0, running: 0, completed: 0 }
|
|
for (const activity of visibleActivities) {
|
|
counts[activity.status === 'running' || activity.status === 'completed' ? activity.status : 'attention'] += 1
|
|
}
|
|
return counts
|
|
}, [visibleActivities])
|
|
const [retryingRecoveryProjectId, setRetryingRecoveryProjectId] =
|
|
useState<string>()
|
|
const [dismissedRecoveryByProjectId, setDismissedRecoveryByProjectId] =
|
|
useState<Record<string, string>>({})
|
|
const recoveryCompletionTimers = useRef(
|
|
new Map<string, { requestId: string; timer: ReturnType<typeof setTimeout> }>()
|
|
)
|
|
|
|
useEffect(() => {
|
|
const timers = recoveryCompletionTimers.current
|
|
for (const [projectId, entry] of timers) {
|
|
const state = recoveryByProjectId[projectId]
|
|
if (state?.stage !== 'completed' || state.requestId !== entry.requestId) {
|
|
clearTimeout(entry.timer)
|
|
timers.delete(projectId)
|
|
}
|
|
}
|
|
for (const [projectId, state] of Object.entries(recoveryByProjectId)) {
|
|
if (state.stage !== 'completed' || timers.has(projectId)) {
|
|
continue
|
|
}
|
|
// Keep the request entry after expiry so menu mounts and snapshots cannot replay it.
|
|
timers.set(projectId, {
|
|
requestId: state.requestId,
|
|
timer: setTimeout(() => {
|
|
setDismissedRecoveryByProjectId((current) => ({
|
|
...current,
|
|
[projectId]: state.requestId
|
|
}))
|
|
}, 3_000)
|
|
})
|
|
}
|
|
}, [recoveryByProjectId])
|
|
|
|
useEffect(() => {
|
|
const timers = recoveryCompletionTimers.current
|
|
return () => {
|
|
for (const entry of timers.values()) {
|
|
clearTimeout(entry.timer)
|
|
}
|
|
timers.clear()
|
|
}
|
|
}, [])
|
|
|
|
const visibleRecoveryByProjectId = useMemo(() => Object.fromEntries(
|
|
Object.entries(recoveryByProjectId).filter(
|
|
([projectId, state]) =>
|
|
state.stage !== 'completed' ||
|
|
dismissedRecoveryByProjectId[projectId] !== state.requestId
|
|
)
|
|
), [recoveryByProjectId, dismissedRecoveryByProjectId])
|
|
const applySshHostsSnapshot = useCallback(
|
|
(
|
|
snapshot: SshHostsSnapshot,
|
|
requestRevision: number
|
|
): void => {
|
|
setSshHosts(snapshot)
|
|
const next = {
|
|
...(snapshot.agentConnectionStatusByHostId ?? {})
|
|
}
|
|
for (const [
|
|
hostId,
|
|
update
|
|
] of liveAgentConnectionStatusRef.current) {
|
|
if (update.revision <= requestRevision) {
|
|
continue
|
|
}
|
|
if (update.state === 'disconnected') {
|
|
delete next[hostId]
|
|
} else {
|
|
next[hostId] = update.state
|
|
}
|
|
}
|
|
setAgentConnectionStatusByHostId((current) => {
|
|
const currentKeys = Object.keys(current)
|
|
const nextKeys = Object.keys(next)
|
|
return currentKeys.length === nextKeys.length &&
|
|
nextKeys.every((hostId) => current[hostId] === next[hostId])
|
|
? current
|
|
: next
|
|
})
|
|
},
|
|
[]
|
|
)
|
|
const beginSshHostsSnapshot = useCallback((): number => {
|
|
pendingSshHostsSnapshotsRef.current += 1
|
|
return agentConnectionStatusRevisionRef.current
|
|
}, [])
|
|
const finishSshHostsSnapshot = useCallback((): void => {
|
|
pendingSshHostsSnapshotsRef.current = Math.max(
|
|
0,
|
|
pendingSshHostsSnapshotsRef.current - 1
|
|
)
|
|
if (pendingSshHostsSnapshotsRef.current === 0) {
|
|
liveAgentConnectionStatusRef.current.clear()
|
|
}
|
|
}, [])
|
|
const retryRecovery = useCallback(
|
|
(projectId: string): void => {
|
|
if (!onRetryRecovery) {
|
|
return
|
|
}
|
|
setRetryingRecoveryProjectId(projectId)
|
|
void onRetryRecovery(projectId)
|
|
.catch(() => undefined)
|
|
.finally(() =>
|
|
setRetryingRecoveryProjectId((current) =>
|
|
current === projectId ? undefined : current
|
|
)
|
|
)
|
|
},
|
|
[onRetryRecovery]
|
|
)
|
|
const [draft, setDraft] = useState<ProjectCreateInput>({
|
|
name: '',
|
|
description: '',
|
|
rootPath: '',
|
|
})
|
|
const activeProject = projects.find(
|
|
(project) => project.id === activeProjectId
|
|
)
|
|
const activeProjectDisplay = activeProject
|
|
? getProjectDisplayText(activeProject, t)
|
|
: undefined
|
|
const activeProjectRecovery =
|
|
activeProject?.executionSpace.kind === 'ssh'
|
|
? visibleRecoveryByProjectId[activeProject.id]
|
|
: undefined
|
|
const settingsProject = projects.find(
|
|
(project) => project.id === settingsProjectId
|
|
)
|
|
const userProjects = useMemo(() => projects.filter((project) =>
|
|
project.kind === 'user' && (project.executionSpace.kind === 'local' || remoteProjectsEnabled)
|
|
), [projects, remoteProjectsEnabled])
|
|
const busy =
|
|
saving ||
|
|
remoteSaving ||
|
|
archiving ||
|
|
deleting
|
|
const remoteFieldsDisabled = busy
|
|
const remoteDirectoryBrowseDisabled =
|
|
remoteFieldsDisabled ||
|
|
loadingSshHosts ||
|
|
!remoteHostId ||
|
|
remoteHostReadiness[remoteHostId]?.status !== 'ready' ||
|
|
dialogMode === 'settings'
|
|
const selectedRemoteHostReadiness =
|
|
remoteHostReadiness[remoteHostId]
|
|
const remoteDraftReady =
|
|
Boolean(
|
|
draft.name.trim() &&
|
|
remoteHostId &&
|
|
selectedRemoteHostReadiness?.status === 'ready' &&
|
|
isRemoteAbsolutePath(remoteRootPath)
|
|
)
|
|
|
|
useEffect(() => {
|
|
if (
|
|
!remoteProjectsEnabled ||
|
|
!dialogMode ||
|
|
executionSpaceKind !== 'ssh'
|
|
) {
|
|
return
|
|
}
|
|
let active = true
|
|
const api = window.goodbuddy?.sshHosts
|
|
if (!api) {
|
|
queueMicrotask(() => {
|
|
if (active) {
|
|
setError(
|
|
t('projectSwitcher.remote.errors.hostsUnavailable')
|
|
)
|
|
setLoadingSshHosts(false)
|
|
}
|
|
})
|
|
return
|
|
}
|
|
const requestRevision = beginSshHostsSnapshot()
|
|
void api
|
|
.getSnapshot()
|
|
.then(
|
|
(snapshot) => {
|
|
if (!active) {
|
|
return
|
|
}
|
|
applySshHostsSnapshot(snapshot, requestRevision)
|
|
const readiness: Record<string, RemoteHostReadiness> =
|
|
Object.fromEntries(
|
|
snapshot.hosts.map((host) => [
|
|
host.id,
|
|
host.hostKey.state === 'verified' &&
|
|
host.lastValidatedAt !== undefined
|
|
? { status: 'ready' as const }
|
|
: { status: 'unready' as const }
|
|
])
|
|
)
|
|
setRemoteHostReadiness(readiness)
|
|
setRemoteHostId((current) =>
|
|
current &&
|
|
snapshot.hosts.some((host) => host.id === current)
|
|
? current
|
|
: snapshot.hosts.find(
|
|
(host) =>
|
|
readiness[host.id]?.status === 'ready'
|
|
)?.id ??
|
|
snapshot.hosts[0]?.id ??
|
|
''
|
|
)
|
|
setLoadingSshHosts(false)
|
|
},
|
|
() => {
|
|
if (active) {
|
|
setError(t('projectSwitcher.remote.errors.loadHosts'))
|
|
setLoadingSshHosts(false)
|
|
}
|
|
}
|
|
)
|
|
.finally(finishSshHostsSnapshot)
|
|
return () => {
|
|
active = false
|
|
}
|
|
}, [
|
|
applySshHostsSnapshot,
|
|
beginSshHostsSnapshot,
|
|
dialogMode,
|
|
executionSpaceKind,
|
|
finishSshHostsSnapshot,
|
|
remoteProjectsEnabled,
|
|
t
|
|
])
|
|
|
|
useEffect(() => {
|
|
if (!remoteProjectsEnabled || !projectMenuOpen) {
|
|
return
|
|
}
|
|
const api = window.goodbuddy?.sshHosts
|
|
if (!api) {
|
|
return
|
|
}
|
|
let active = true
|
|
const requestRevision = beginSshHostsSnapshot()
|
|
void api
|
|
.getSnapshot()
|
|
.then(
|
|
(snapshot) => {
|
|
if (!active) {
|
|
return
|
|
}
|
|
applySshHostsSnapshot(snapshot, requestRevision)
|
|
},
|
|
() => undefined
|
|
)
|
|
.finally(finishSshHostsSnapshot)
|
|
return () => {
|
|
active = false
|
|
}
|
|
}, [
|
|
applySshHostsSnapshot,
|
|
beginSshHostsSnapshot,
|
|
finishSshHostsSnapshot,
|
|
projectMenuOpen,
|
|
remoteProjectsEnabled
|
|
])
|
|
|
|
useEffect(() => {
|
|
const api = remoteProjectsEnabled
|
|
? window.goodbuddy?.sshHosts
|
|
: undefined
|
|
if (!api) {
|
|
return
|
|
}
|
|
return api.onAgentConnectionStatus(({ hostId, state }) => {
|
|
const revision =
|
|
agentConnectionStatusRevisionRef.current + 1
|
|
agentConnectionStatusRevisionRef.current = revision
|
|
liveAgentConnectionStatusRef.current.set(hostId, {
|
|
revision,
|
|
state
|
|
})
|
|
setAgentConnectionStatusByHostId((current) => {
|
|
if (state === 'disconnected') {
|
|
if (!(hostId in current)) {
|
|
return current
|
|
}
|
|
const next = { ...current }
|
|
delete next[hostId]
|
|
return next
|
|
}
|
|
return current[hostId] === state
|
|
? current
|
|
: { ...current, [hostId]: state }
|
|
})
|
|
})
|
|
}, [remoteProjectsEnabled])
|
|
|
|
useEffect(() => {
|
|
if (
|
|
!remoteProjectsEnabled ||
|
|
!dialogMode ||
|
|
executionSpaceKind !== 'ssh'
|
|
) {
|
|
return
|
|
}
|
|
const remoteApi = window.goodbuddy.projects.remote
|
|
if (!remoteApi) {
|
|
return
|
|
}
|
|
return remoteApi.onSaveProgress((progress) =>
|
|
setRemoteSavePhase(progress.phase)
|
|
)
|
|
}, [dialogMode, executionSpaceKind, remoteProjectsEnabled])
|
|
|
|
useEffect(() => {
|
|
if (
|
|
remoteProjectsEnabled ||
|
|
dialogMode !== 'settings' ||
|
|
settingsProject?.executionSpace.kind !== 'ssh'
|
|
) {
|
|
return
|
|
}
|
|
let active = true
|
|
queueMicrotask(() => {
|
|
if (active) {
|
|
setConfirmingDelete(false)
|
|
setDeleteConfirmation('')
|
|
setDialogMode(undefined)
|
|
}
|
|
})
|
|
return () => {
|
|
active = false
|
|
}
|
|
}, [dialogMode, remoteProjectsEnabled, settingsProject])
|
|
|
|
const resetRemoteDraft = (): void => {
|
|
directoryBrowseRequestRef.current += 1
|
|
setSshHosts(undefined)
|
|
setLoadingSshHosts(false)
|
|
setRemoteHostReadiness({})
|
|
setRemoteHostId('')
|
|
setRemoteRootPath('')
|
|
setRemoteSaving(false)
|
|
setRemoteSavePhase(undefined)
|
|
setDirectoryPickerOpen(false)
|
|
setDirectoryPickerLoading(false)
|
|
setDirectoryPickerError(undefined)
|
|
setRemoteDirectoryListing(undefined)
|
|
}
|
|
|
|
const cancelRemoteDirectoryBrowse = useCallback((): void => {
|
|
directoryBrowseRequestRef.current += 1
|
|
const api = remoteProjectsEnabled
|
|
? window.goodbuddy.sshHosts
|
|
: undefined
|
|
if (api) {
|
|
void api.cancelDirectoryBrowse().catch(() => undefined)
|
|
}
|
|
setDirectoryPickerOpen(false)
|
|
setDirectoryPickerLoading(false)
|
|
setDirectoryPickerError(undefined)
|
|
setRemoteDirectoryListing(undefined)
|
|
requestAnimationFrame(() =>
|
|
directoryPickerTriggerRef.current?.focus()
|
|
)
|
|
}, [remoteProjectsEnabled])
|
|
|
|
const browseRemoteDirectories = async (
|
|
path?: string
|
|
): Promise<void> => {
|
|
if (!remoteProjectsEnabled) {
|
|
return
|
|
}
|
|
const api = window.goodbuddy.sshHosts
|
|
if (!api || !remoteHostId) {
|
|
return
|
|
}
|
|
const requestId = ++directoryBrowseRequestRef.current
|
|
setDirectoryPickerLoading(true)
|
|
setDirectoryPickerError(undefined)
|
|
try {
|
|
const response = await api.browseDirectories(remoteHostId, path)
|
|
if (requestId !== directoryBrowseRequestRef.current) {
|
|
return
|
|
}
|
|
setRemoteDirectoryListing(response)
|
|
} catch (reason) {
|
|
if (requestId !== directoryBrowseRequestRef.current) {
|
|
return
|
|
}
|
|
const message = displayErrorMessage(
|
|
reason,
|
|
t('projectSwitcher.remote.directoryPicker.unknownError')
|
|
)
|
|
setDirectoryPickerError(
|
|
t('projectSwitcher.remote.directoryPicker.loadError', {
|
|
message
|
|
})
|
|
)
|
|
} finally {
|
|
if (requestId === directoryBrowseRequestRef.current) {
|
|
setDirectoryPickerLoading(false)
|
|
}
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
if (!directoryPickerOpen) {
|
|
return
|
|
}
|
|
const focusFrame = requestAnimationFrame(() => {
|
|
const target =
|
|
directoryPickerRef.current?.querySelector<HTMLButtonElement>(
|
|
'button:not(:disabled)'
|
|
)
|
|
target?.focus()
|
|
})
|
|
return () => cancelAnimationFrame(focusFrame)
|
|
}, [directoryPickerOpen])
|
|
|
|
useEffect(() => {
|
|
if (!dialogMode) return
|
|
return activateModalFocus(
|
|
() => dialogRef.current?.querySelector<HTMLElement>('input:not(:disabled), textarea:not(:disabled), select:not(:disabled)') ??
|
|
dialogRef.current?.querySelector<HTMLElement>('button:not(:disabled)') ?? null,
|
|
() => projectPickerButtonRef.current
|
|
)
|
|
}, [dialogMode])
|
|
|
|
useEffect(() => {
|
|
if (!dialogMode) {
|
|
if (restoreFocusTarget.current === 'create') {
|
|
projectPickerButtonRef.current?.focus()
|
|
} else if (restoreFocusTarget.current === 'picker') {
|
|
projectPickerButtonRef.current?.focus()
|
|
}
|
|
restoreFocusTarget.current = undefined
|
|
return
|
|
}
|
|
const onKeyDown = (event: KeyboardEvent): void => {
|
|
if (event.defaultPrevented || dialogRef.current?.closest('[inert]')) return
|
|
if (directoryPickerOpen) {
|
|
if (event.key === 'Escape') {
|
|
event.preventDefault()
|
|
cancelRemoteDirectoryBrowse()
|
|
return
|
|
}
|
|
trapTabFocus(event, directoryPickerRef.current)
|
|
return
|
|
}
|
|
if (event.key === 'Escape' && !archiving && !deleting) {
|
|
event.preventDefault()
|
|
if (remoteProjectsEnabled && remoteSaving) {
|
|
void window.goodbuddy.projects.remote
|
|
.cancelCurrent()
|
|
.catch(() => undefined)
|
|
}
|
|
setConfirmingDelete(false)
|
|
setDeleteConfirmation('')
|
|
setDialogMode(undefined)
|
|
return
|
|
}
|
|
trapTabFocus(event, dialogRef.current)
|
|
}
|
|
document.addEventListener('keydown', onKeyDown)
|
|
return () => document.removeEventListener('keydown', onKeyDown)
|
|
}, [
|
|
archiving,
|
|
cancelRemoteDirectoryBrowse,
|
|
deleting,
|
|
dialogMode,
|
|
directoryPickerOpen,
|
|
remoteProjectsEnabled,
|
|
remoteSaving
|
|
])
|
|
|
|
const closeDialog = (): void => {
|
|
directoryBrowseRequestRef.current += 1
|
|
if (directoryPickerOpen) {
|
|
const api = remoteProjectsEnabled
|
|
? window.goodbuddy.sshHosts
|
|
: undefined
|
|
if (api) {
|
|
void api.cancelDirectoryBrowse().catch(() => undefined)
|
|
}
|
|
}
|
|
if (remoteProjectsEnabled && remoteSaving) {
|
|
void window.goodbuddy.projects.remote
|
|
.cancelCurrent()
|
|
.catch(() => undefined)
|
|
}
|
|
setConfirmingDelete(false)
|
|
setDeleteConfirmation('')
|
|
setDialogMode(undefined)
|
|
setSettingsProjectId(undefined)
|
|
}
|
|
|
|
// The project form always shows a concrete execution mode, so save exactly that mode.
|
|
const withProjectProvider = (
|
|
layer: RuntimeSelectionLayer | undefined,
|
|
remote: boolean
|
|
): RuntimeSelectionLayer | undefined =>
|
|
runtimeSettings
|
|
? {
|
|
...layer,
|
|
provider: resolveRuntimeChoice(runtimeSettings, { project: layer }, { remote }).provider
|
|
}
|
|
: layer
|
|
|
|
const openProjectSettings = (project: AssistantProject): void => {
|
|
if (
|
|
!remoteProjectsEnabled &&
|
|
project.executionSpace.kind === 'ssh'
|
|
) {
|
|
return
|
|
}
|
|
setProjectMenuOpen(false)
|
|
setError(undefined)
|
|
setConfirmingDelete(false)
|
|
setDeleteConfirmation('')
|
|
resetRemoteDraft()
|
|
setSettingsProjectId(project.id)
|
|
const nextExecutionSpaceKind =
|
|
project.executionSpace.kind === 'ssh' ? 'ssh' : 'local'
|
|
setExecutionSpaceKind(nextExecutionSpaceKind)
|
|
if (project.executionSpace.kind === 'ssh') {
|
|
setLoadingSshHosts(true)
|
|
setRemoteHostId(project.executionSpace.hostId)
|
|
setRemoteRootPath(project.executionSpace.remoteRootPath)
|
|
}
|
|
const nextDraft: ProjectCreateInput = {
|
|
name: project.name,
|
|
description: project.description,
|
|
rootPath: project.rootPath,
|
|
// Keep "follow global" as absent so saving never freezes today's default.
|
|
runtimeSelection: project.runtimeSelection
|
|
}
|
|
setDraft(
|
|
project.kind === 'channel' && runtimeSettings
|
|
? channelProjectDraft(nextDraft, runtimeSettings)
|
|
: nextDraft
|
|
)
|
|
restoreFocusTarget.current = 'picker'
|
|
setDialogMode('settings')
|
|
}
|
|
|
|
const validateRemoteDraft = (): boolean => {
|
|
if (!remoteHostId) {
|
|
setError(t('projectSwitcher.remote.validation.host'))
|
|
return false
|
|
}
|
|
if (remoteHostReadiness[remoteHostId]?.status !== 'ready') {
|
|
setError(
|
|
t('projectSwitcher.remote.readiness.saveBlocked')
|
|
)
|
|
return false
|
|
}
|
|
if (!isRemoteAbsolutePath(remoteRootPath)) {
|
|
setError(t('projectSwitcher.remote.validation.root'))
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
const saveRemoteProject = async (): Promise<void> => {
|
|
setError(undefined)
|
|
if (
|
|
!remoteProjectsEnabled ||
|
|
!draft.name.trim() ||
|
|
!validateRemoteDraft()
|
|
) {
|
|
return
|
|
}
|
|
setRemoteSaving(true)
|
|
setRemoteSavePhase('host')
|
|
try {
|
|
const commonDraft = {
|
|
name: draft.name,
|
|
description: draft.description,
|
|
runtimeSelection: withProjectProvider(draft.runtimeSelection, true),
|
|
hostId: remoteHostId,
|
|
remoteRootPath
|
|
}
|
|
const result =
|
|
await window.goodbuddy.projects.remote.save(
|
|
dialogMode === 'settings' && settingsProject
|
|
? {
|
|
intent: 'update',
|
|
draft: {
|
|
...commonDraft,
|
|
projectId: settingsProject.id
|
|
}
|
|
}
|
|
: {
|
|
intent: 'create',
|
|
draft: commonDraft
|
|
}
|
|
)
|
|
await onRemoteCommitted(result)
|
|
closeDialog()
|
|
} catch (reason) {
|
|
setError(
|
|
displayErrorMessage(
|
|
reason,
|
|
t('projectSwitcher.remote.errors.save')
|
|
)
|
|
)
|
|
} finally {
|
|
setRemoteSaving(false)
|
|
setRemoteSavePhase(undefined)
|
|
}
|
|
}
|
|
|
|
const save = async (): Promise<void> => {
|
|
if (dialogMode === 'create' && !draft.rootPath.trim()) return
|
|
setSaving(true)
|
|
setError(undefined)
|
|
try {
|
|
const input = {
|
|
...draft,
|
|
runtimeSelection: withProjectProvider(draft.runtimeSelection, false)
|
|
}
|
|
if (dialogMode === 'settings' && settingsProject) {
|
|
await onUpdate(settingsProject.id, input)
|
|
} else {
|
|
await onCreate(input)
|
|
}
|
|
closeDialog()
|
|
} catch (reason) {
|
|
setError(
|
|
displayErrorMessage(
|
|
reason,
|
|
dialogMode === 'settings'
|
|
? t('projectSwitcher.errors.save')
|
|
: t('projectSwitcher.errors.create')
|
|
)
|
|
)
|
|
} finally {
|
|
setSaving(false)
|
|
}
|
|
}
|
|
|
|
const selectRoot = async (): Promise<void> => {
|
|
setError(undefined)
|
|
try {
|
|
const rootPath = await onSelectRoot()
|
|
if (rootPath) {
|
|
setDraft((current) => ({
|
|
...current,
|
|
rootPath
|
|
}))
|
|
}
|
|
} catch (reason) {
|
|
setError(
|
|
displayErrorMessage(
|
|
reason,
|
|
t('projectSwitcher.errors.selectRoot')
|
|
)
|
|
)
|
|
}
|
|
}
|
|
|
|
const archive = async (): Promise<void> => {
|
|
if (!settingsProject) {
|
|
return
|
|
}
|
|
setArchiving(true)
|
|
setError(undefined)
|
|
try {
|
|
await onArchive(settingsProject.id)
|
|
closeDialog()
|
|
} catch (reason) {
|
|
setError(
|
|
displayErrorMessage(
|
|
reason,
|
|
t('projectSwitcher.errors.archive')
|
|
)
|
|
)
|
|
} finally {
|
|
setArchiving(false)
|
|
}
|
|
}
|
|
|
|
const deleteProject = async (): Promise<void> => {
|
|
if (!settingsProject) {
|
|
return
|
|
}
|
|
setDeleting(true)
|
|
setError(undefined)
|
|
try {
|
|
await onDelete(settingsProject.id, deleteConfirmation)
|
|
closeDialog()
|
|
} catch (reason) {
|
|
setError(
|
|
displayErrorMessage(
|
|
reason,
|
|
t('projectSwitcher.errors.delete')
|
|
)
|
|
)
|
|
} finally {
|
|
setDeleting(false)
|
|
}
|
|
}
|
|
|
|
const renderProjectMenuItem = (
|
|
project: AssistantProject
|
|
): React.JSX.Element => {
|
|
const ProjectIcon: LucideIcon = project.kind === 'channel' ? RadioTower : Folder
|
|
const selected = project.id === activeProjectId
|
|
const projectDisplay = getProjectDisplayText(project, t)
|
|
const recovery =
|
|
project.executionSpace.kind === 'ssh'
|
|
? visibleRecoveryByProjectId[project.id]
|
|
: undefined
|
|
const detail =
|
|
project.kind === 'channel'
|
|
? t('projectSwitcher.selector.remoteDetail', {
|
|
channel: project.channel
|
|
? projectChannelLabels[project.channel]
|
|
: t('projectSwitcher.selector.remoteChannel'),
|
|
path: project.rootPath
|
|
})
|
|
: project.executionSpace.kind === 'ssh'
|
|
? t('projectSwitcher.selector.remotePath', {
|
|
path: project.rootPath
|
|
})
|
|
: t('projectSwitcher.selector.localDetail', {
|
|
path: project.rootPath
|
|
})
|
|
return (
|
|
<div
|
|
className={`project-switcher__menu-item${
|
|
recovery?.stage === 'failed' && recovery.retryable
|
|
? ' project-switcher__menu-item--with-retry'
|
|
: ''
|
|
}`}
|
|
key={project.id}
|
|
>
|
|
<button
|
|
aria-checked={selected}
|
|
onClick={() => {
|
|
onSelect(project.id)
|
|
setProjectMenuOpen(false)
|
|
}}
|
|
role="menuitemradio"
|
|
tabIndex={0}
|
|
type="button"
|
|
>
|
|
{project.kind === 'channel' && project.channel ? (
|
|
<ChannelIcon channel={project.channel} />
|
|
) : (
|
|
<ProjectIcon aria-hidden="true" size={16} />
|
|
)}
|
|
<span>
|
|
<span className="project-switcher__project-heading">
|
|
<b>{projectDisplay.name}</b>
|
|
</span>
|
|
{activityByProjectId[project.id] && (
|
|
<ProjectActivityCounts {...activityByProjectId[project.id]!} />
|
|
)}
|
|
<small>{detail}</small>
|
|
{recovery && <ProjectRecoveryStatus state={recovery} />}
|
|
</span>
|
|
{selected && <Check aria-hidden="true" size={14} />}
|
|
</button>
|
|
{recovery?.stage === 'failed' &&
|
|
recovery.retryable &&
|
|
onRetryRecovery && (
|
|
<button
|
|
aria-label={t(
|
|
'projectSwitcher.recovery.retryNamed',
|
|
{ name: projectDisplay.name }
|
|
)}
|
|
className="project-switcher__menu-retry"
|
|
disabled={retryingRecoveryProjectId === project.id}
|
|
onClick={() => retryRecovery(project.id)}
|
|
role="menuitem"
|
|
tabIndex={0}
|
|
type="button"
|
|
>
|
|
<RefreshCw aria-hidden="true" size={14} />
|
|
</button>
|
|
)}
|
|
<button
|
|
aria-label={t(
|
|
'projectSwitcher.selector.settingsNamed',
|
|
{ name: projectDisplay.name }
|
|
)}
|
|
className="project-switcher__menu-settings"
|
|
onClick={() => openProjectSettings(project)}
|
|
role="menuitem"
|
|
tabIndex={0}
|
|
type="button"
|
|
>
|
|
<Settings aria-hidden="true" size={14} />
|
|
</button>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="project-switcher">
|
|
<div className="project-switcher__row">
|
|
<div
|
|
className="project-switcher__picker"
|
|
ref={projectPickerRef}
|
|
>
|
|
<button
|
|
aria-expanded={projectMenuOpen}
|
|
aria-haspopup="dialog"
|
|
aria-controls={projectMenuOpen ? menuId : undefined}
|
|
aria-label={t('projectSwitcher.selector.ariaLabel')}
|
|
aria-describedby={`${menuId}-activity${activeProjectRecovery ? ` ${menuId}-recovery` : ''}`}
|
|
className="project-switcher__trigger project-switcher__control"
|
|
onClick={(event) => {
|
|
event.currentTarget.focus()
|
|
setProjectMenuOpen((open) => !open)
|
|
}}
|
|
onKeyDown={(event) => {
|
|
if (
|
|
!projectMenuOpen &&
|
|
(event.key === 'ArrowDown' ||
|
|
event.key === 'Enter' ||
|
|
event.key === ' ')
|
|
) {
|
|
event.preventDefault()
|
|
setProjectMenuOpen(true)
|
|
}
|
|
}}
|
|
ref={projectPickerButtonRef}
|
|
title={activeProjectDisplay?.name}
|
|
type="button"
|
|
>
|
|
<Folder aria-hidden="true" size={16} />
|
|
<span className="project-switcher__trigger-copy">
|
|
<span>
|
|
{activeProjectDisplay?.name ??
|
|
t('projectSwitcher.selector.empty')}
|
|
</span>
|
|
<span className="project-switcher__activity" id={`${menuId}-activity`}>
|
|
{visibleActivities.length > 0
|
|
? <ProjectActivityCounts {...activityCounts} /> : t('projectActivity.idle')}
|
|
</span>
|
|
</span>
|
|
<ChevronDown aria-hidden="true" size={14} />
|
|
</button>
|
|
{activeProjectRecovery && <span id={`${menuId}-recovery`} className="project-switcher__recovery-feedback">
|
|
<ProjectRecoveryStatus state={activeProjectRecovery} />
|
|
</span>}
|
|
{projectMenuOpen && <WorkspaceMenu
|
|
id={menuId} projects={projects} activeProjectId={activeProjectId}
|
|
activities={visibleActivities} conversationStore={conversationStore} remoteProjectsEnabled={remoteProjectsEnabled}
|
|
hosts={sshHosts?.hosts ?? emptyHosts} connectionStates={agentConnectionStatusByHostId}
|
|
anchorRef={projectPickerButtonRef} controlsRef={projectPickerRef}
|
|
renderProject={renderProjectMenuItem} onClose={closeProjectMenu}
|
|
onEnterProject={onSelect} onRestore={onRestore} notify={notify}
|
|
onNewConversation={onNewConversation} onOpenConversation={onOpenConversation}
|
|
onCreateProject={() => {
|
|
setProjectMenuOpen(false)
|
|
setError(undefined)
|
|
setConfirmingDelete(false)
|
|
setDeleteConfirmation('')
|
|
setSettingsProjectId(undefined)
|
|
resetRemoteDraft()
|
|
setExecutionSpaceKind('local')
|
|
setDraft({
|
|
name: '',
|
|
description: '',
|
|
rootPath: '',
|
|
})
|
|
restoreFocusTarget.current = 'create'
|
|
setDialogMode('create')
|
|
}} />}
|
|
</div>
|
|
</div>
|
|
{dialogMode && createPortal(
|
|
<div
|
|
className="project-create-backdrop"
|
|
onMouseDown={(event) => {
|
|
if (event.currentTarget === event.target && !busy) {
|
|
closeDialog()
|
|
}
|
|
}}
|
|
>
|
|
<div
|
|
aria-labelledby="project-dialog-title"
|
|
aria-modal="true"
|
|
className="project-create-card"
|
|
ref={dialogRef}
|
|
role="dialog"
|
|
>
|
|
<header>
|
|
<strong id="project-dialog-title">
|
|
{dialogMode === 'create'
|
|
? t('projectSwitcher.dialog.createTitle')
|
|
: t('projectSwitcher.dialog.settingsTitle')}
|
|
</strong>
|
|
<button
|
|
aria-label={
|
|
dialogMode === 'create'
|
|
? t('projectSwitcher.dialog.closeCreate')
|
|
: t('projectSwitcher.dialog.closeSettings')
|
|
}
|
|
className="icon-button project-create-card__close"
|
|
disabled={busy}
|
|
onClick={() => closeDialog()}
|
|
type="button"
|
|
>
|
|
<X size={18} />
|
|
</button>
|
|
</header>
|
|
<div className="project-create-card__body">
|
|
{dialogMode === 'settings' &&
|
|
settingsProject?.kind === 'channel' &&
|
|
runtimeSettings ? (
|
|
<ChannelProjectSettingsFields
|
|
autoFocus
|
|
disabled={busy}
|
|
onChange={setDraft}
|
|
onSelectRoot={() => void selectRoot()}
|
|
runtimeSettings={runtimeSettings}
|
|
value={draft}
|
|
variant="dialog"
|
|
/>
|
|
) : (
|
|
<>
|
|
<label>
|
|
<span>{t('projectSwitcher.dialog.fields.name')}</span>
|
|
<input
|
|
autoFocus={!confirmingDelete}
|
|
disabled={
|
|
executionSpaceKind === 'ssh'
|
|
? remoteFieldsDisabled
|
|
: busy
|
|
}
|
|
maxLength={120}
|
|
onChange={(event) =>
|
|
setDraft((current) => ({
|
|
...current,
|
|
name: event.target.value
|
|
}))
|
|
}
|
|
value={draft.name}
|
|
/>
|
|
</label>
|
|
<label>
|
|
<span>
|
|
{t('projectSwitcher.dialog.fields.description')}
|
|
</span>
|
|
<textarea
|
|
disabled={
|
|
executionSpaceKind === 'ssh'
|
|
? remoteFieldsDisabled
|
|
: busy
|
|
}
|
|
maxLength={2_000}
|
|
onChange={(event) =>
|
|
setDraft((current) => ({
|
|
...current,
|
|
description: event.target.value
|
|
}))
|
|
}
|
|
rows={3}
|
|
value={draft.description}
|
|
/>
|
|
</label>
|
|
<div className="project-execution-space">
|
|
<span>
|
|
{t(
|
|
'projectSwitcher.dialog.fields.executionSpace'
|
|
)}
|
|
</span>
|
|
<SegmentedControl
|
|
ariaLabel={t(
|
|
'projectSwitcher.dialog.fields.executionSpace'
|
|
)}
|
|
disabled={dialogMode === 'settings' || busy}
|
|
onChange={(kind) => {
|
|
setError(undefined)
|
|
setRemoteSavePhase(undefined)
|
|
setExecutionSpaceKind(kind)
|
|
if (kind === 'ssh') {
|
|
setLoadingSshHosts(true)
|
|
setDraft((current) => ({
|
|
...current,
|
|
runtimeSelection:
|
|
current.runtimeSelection?.provider === 'opencode' || current.runtimeSelection?.provider === 'continue'
|
|
? current.runtimeSelection
|
|
: { provider: 'opencode' }
|
|
}))
|
|
}
|
|
}}
|
|
options={[
|
|
{
|
|
value: 'local',
|
|
label: t(
|
|
'projectSwitcher.remote.executionSpaces.local'
|
|
)
|
|
},
|
|
...(remoteProjectsEnabled
|
|
? [
|
|
{
|
|
value: 'ssh' as const,
|
|
label: t(
|
|
'projectSwitcher.remote.executionSpaces.ssh'
|
|
)
|
|
}
|
|
]
|
|
: [])
|
|
]}
|
|
value={executionSpaceKind}
|
|
/>
|
|
{dialogMode === 'settings' && (
|
|
<small>
|
|
{t(
|
|
'projectSwitcher.remote.executionSpaceFixed'
|
|
)}
|
|
</small>
|
|
)}
|
|
</div>
|
|
{executionSpaceKind === 'local' ? (
|
|
<>
|
|
<label>
|
|
<span>
|
|
{t('projectSwitcher.dialog.fields.rootPath')}
|
|
</span>
|
|
<div className="project-create-card__path">
|
|
<input
|
|
aria-required={dialogMode === 'create' || undefined}
|
|
aria-describedby={dialogMode === 'create' ? 'project-root-required' : undefined}
|
|
readOnly
|
|
value={draft.rootPath}
|
|
/>
|
|
<button
|
|
aria-label={t(
|
|
'projectSwitcher.dialog.selectRoot'
|
|
)}
|
|
className="secondary-button"
|
|
disabled={busy}
|
|
onClick={() => void selectRoot()}
|
|
type="button"
|
|
>
|
|
<FolderOpen size={14} />
|
|
</button>
|
|
</div>
|
|
</label>
|
|
{dialogMode === 'create' && (
|
|
<small id="project-root-required">
|
|
{t('projectSwitcher.dialog.rootRequired')}
|
|
</small>
|
|
)}
|
|
{runtimeSettings && (
|
|
<>
|
|
<ProjectRuntimeSelector
|
|
disabled={busy}
|
|
help={t(
|
|
'projectSwitcher.dialog.defaultRuntimeHelp'
|
|
)}
|
|
label={t(
|
|
'projectSwitcher.dialog.fields.defaultRuntime'
|
|
)}
|
|
onChange={(runtimeSelection) =>
|
|
setDraft((current) => ({
|
|
...current,
|
|
runtimeSelection
|
|
}))
|
|
}
|
|
runtimeSettings={runtimeSettings}
|
|
selection={draft.runtimeSelection}
|
|
/>
|
|
</>
|
|
)}
|
|
</>
|
|
) : (
|
|
<div className="remote-project-fields">
|
|
<div className="field">
|
|
<div className="inline-help-label">
|
|
<label htmlFor="project-remote-host">
|
|
{t('projectSwitcher.remote.fields.host')}
|
|
</label>
|
|
<InlineHelp label={t('projectSwitcher.remote.fields.host')}>{t('projectSwitcher.remote.hostHelp')}</InlineHelp>
|
|
</div>
|
|
<select
|
|
id="project-remote-host"
|
|
aria-label={t(
|
|
'projectSwitcher.remote.fields.host'
|
|
)}
|
|
disabled={
|
|
remoteFieldsDisabled ||
|
|
loadingSshHosts ||
|
|
dialogMode === 'settings'
|
|
}
|
|
onChange={(event) => {
|
|
setError(undefined)
|
|
setRemoteHostId(event.target.value)
|
|
}}
|
|
value={remoteHostId}
|
|
>
|
|
{loadingSshHosts && (
|
|
<option value="">
|
|
{t(
|
|
'projectSwitcher.remote.loadingHosts'
|
|
)}
|
|
</option>
|
|
)}
|
|
{!loadingSshHosts &&
|
|
sshHosts?.hosts.length === 0 && (
|
|
<option value="">
|
|
{t(
|
|
'projectSwitcher.remote.noHosts'
|
|
)}
|
|
</option>
|
|
)}
|
|
{sshHosts?.hosts.map((host) => {
|
|
const readiness =
|
|
remoteHostReadiness[host.id]
|
|
const readinessLabel = readiness
|
|
? t(
|
|
`projectSwitcher.remote.readiness.options.${readiness.status}`
|
|
)
|
|
: t(
|
|
'projectSwitcher.remote.readiness.options.loading'
|
|
)
|
|
return (
|
|
<option
|
|
disabled={readiness?.status !== 'ready'}
|
|
key={host.id}
|
|
value={host.id}
|
|
>
|
|
{host.name} · {host.username}@
|
|
{host.hostname} · {readinessLabel}
|
|
</option>
|
|
)
|
|
})}
|
|
</select>
|
|
{remoteHostId &&
|
|
selectedRemoteHostReadiness && (
|
|
<small
|
|
aria-live="polite"
|
|
role={
|
|
selectedRemoteHostReadiness.status ===
|
|
'unready'
|
|
? 'alert'
|
|
: 'status'
|
|
}
|
|
>
|
|
{t(
|
|
`projectSwitcher.remote.readiness.${selectedRemoteHostReadiness.status}`
|
|
)}
|
|
</small>
|
|
)}
|
|
</div>
|
|
<label>
|
|
<span>
|
|
{t('projectSwitcher.remote.fields.root')}
|
|
</span>
|
|
<div className="project-create-card__path">
|
|
<input
|
|
aria-label={t(
|
|
'projectSwitcher.remote.fields.root'
|
|
)}
|
|
disabled={
|
|
remoteFieldsDisabled ||
|
|
dialogMode === 'settings'
|
|
}
|
|
maxLength={4_096}
|
|
onChange={(event) =>
|
|
setRemoteRootPath(event.target.value)
|
|
}
|
|
placeholder="/home/user/project"
|
|
value={remoteRootPath}
|
|
/>
|
|
<button
|
|
aria-label={t(
|
|
'projectSwitcher.remote.directoryPicker.browse'
|
|
)}
|
|
className="secondary-button"
|
|
disabled={remoteDirectoryBrowseDisabled}
|
|
onClick={() => {
|
|
setDirectoryPickerOpen(true)
|
|
setRemoteDirectoryListing(undefined)
|
|
void browseRemoteDirectories(
|
|
isRemoteAbsolutePath(remoteRootPath)
|
|
? remoteRootPath
|
|
: undefined
|
|
)
|
|
}}
|
|
ref={directoryPickerTriggerRef}
|
|
title={t(
|
|
'projectSwitcher.remote.directoryPicker.browse'
|
|
)}
|
|
type="button"
|
|
>
|
|
<FolderOpen aria-hidden="true" size={14} />
|
|
</button>
|
|
</div>
|
|
<small>
|
|
{t('projectSwitcher.remote.rootHelp')}
|
|
</small>
|
|
</label>
|
|
{runtimeSettings && (
|
|
<ProjectRuntimeSelector
|
|
disabled={remoteFieldsDisabled}
|
|
help={t('projectSwitcher.remote.runtimeHelp')}
|
|
label={t(
|
|
'projectSwitcher.dialog.fields.defaultRuntime'
|
|
)}
|
|
onChange={(runtimeSelection) =>
|
|
setDraft((current) => ({
|
|
...current,
|
|
runtimeSelection
|
|
}))
|
|
}
|
|
remote
|
|
runtimeSettings={runtimeSettings}
|
|
selection={draft.runtimeSelection}
|
|
/>
|
|
)}
|
|
{remoteSaving && remoteSavePhase && (
|
|
<RemoteProjectProgress
|
|
phase={remoteSavePhase}
|
|
title={t(
|
|
'projectSwitcher.remote.actions.saving'
|
|
)}
|
|
/>
|
|
)}
|
|
</div>
|
|
)}
|
|
{directoryPickerOpen && (
|
|
<div
|
|
className="remote-directory-picker-backdrop"
|
|
onMouseDown={(event) => {
|
|
if (event.currentTarget === event.target) {
|
|
cancelRemoteDirectoryBrowse()
|
|
}
|
|
}}
|
|
>
|
|
<section
|
|
aria-labelledby="remote-directory-picker-title"
|
|
aria-modal="true"
|
|
className="remote-directory-picker"
|
|
ref={directoryPickerRef}
|
|
role="dialog"
|
|
>
|
|
<header>
|
|
<strong id="remote-directory-picker-title">
|
|
{t(
|
|
'projectSwitcher.remote.directoryPicker.title'
|
|
)}
|
|
</strong>
|
|
<button
|
|
aria-label={t(
|
|
'projectSwitcher.remote.directoryPicker.close'
|
|
)}
|
|
className="icon-button"
|
|
onClick={() =>
|
|
cancelRemoteDirectoryBrowse()
|
|
}
|
|
title={t(
|
|
'projectSwitcher.remote.directoryPicker.close'
|
|
)}
|
|
type="button"
|
|
>
|
|
<X aria-hidden="true" size={14} />
|
|
</button>
|
|
</header>
|
|
<div className="remote-directory-picker__location">
|
|
<span>
|
|
{t(
|
|
'projectSwitcher.remote.directoryPicker.currentPath'
|
|
)}
|
|
</span>
|
|
<code>
|
|
{remoteDirectoryListing?.path ??
|
|
(isRemoteAbsolutePath(remoteRootPath)
|
|
? remoteRootPath
|
|
: '…')}
|
|
</code>
|
|
</div>
|
|
<div className="remote-directory-picker__toolbar">
|
|
<button
|
|
aria-label={t(
|
|
'projectSwitcher.remote.directoryPicker.parent'
|
|
)}
|
|
className="icon-button"
|
|
disabled={
|
|
directoryPickerLoading ||
|
|
!remoteDirectoryListing ||
|
|
!remoteDirectoryListing.parentPath
|
|
}
|
|
onClick={() => {
|
|
const parentPath =
|
|
remoteDirectoryListing?.parentPath
|
|
if (parentPath) {
|
|
void browseRemoteDirectories(parentPath)
|
|
}
|
|
}}
|
|
title={t(
|
|
'projectSwitcher.remote.directoryPicker.parent'
|
|
)}
|
|
type="button"
|
|
>
|
|
<ArrowUp aria-hidden="true" size={15} />
|
|
</button>
|
|
<button
|
|
aria-label={t(
|
|
'projectSwitcher.remote.directoryPicker.refresh'
|
|
)}
|
|
className="icon-button"
|
|
disabled={directoryPickerLoading}
|
|
onClick={() =>
|
|
void browseRemoteDirectories(
|
|
remoteDirectoryListing?.path ??
|
|
(isRemoteAbsolutePath(
|
|
remoteRootPath
|
|
)
|
|
? remoteRootPath
|
|
: undefined)
|
|
)
|
|
}
|
|
title={t(
|
|
'projectSwitcher.remote.directoryPicker.refresh'
|
|
)}
|
|
type="button"
|
|
>
|
|
<RefreshCw aria-hidden="true" size={15} />
|
|
</button>
|
|
</div>
|
|
<div
|
|
aria-busy={directoryPickerLoading}
|
|
className="remote-directory-picker__content"
|
|
>
|
|
{directoryPickerLoading ? (
|
|
<p aria-live="polite" role="status">
|
|
{t(
|
|
'projectSwitcher.remote.directoryPicker.loading'
|
|
)}
|
|
</p>
|
|
) : directoryPickerError ? (
|
|
<p role="alert">{directoryPickerError}</p>
|
|
) : remoteDirectoryListing?.entries.length ? (
|
|
<ul
|
|
aria-label={t(
|
|
'projectSwitcher.remote.directoryPicker.title'
|
|
)}
|
|
>
|
|
{remoteDirectoryListing.entries.map(
|
|
(directory) => (
|
|
<li key={directory.path}>
|
|
<button
|
|
aria-label={t(
|
|
'projectSwitcher.remote.directoryPicker.directory',
|
|
{ name: directory.name }
|
|
)}
|
|
onClick={() =>
|
|
void browseRemoteDirectories(
|
|
directory.path
|
|
)
|
|
}
|
|
type="button"
|
|
>
|
|
<Folder
|
|
aria-hidden="true"
|
|
size={16}
|
|
/>
|
|
<span>{directory.name}</span>
|
|
</button>
|
|
</li>
|
|
)
|
|
)}
|
|
</ul>
|
|
) : (
|
|
<p>
|
|
{t(
|
|
'projectSwitcher.remote.directoryPicker.empty'
|
|
)}
|
|
</p>
|
|
)}
|
|
</div>
|
|
<div className="remote-directory-picker__actions">
|
|
<button
|
|
className="secondary-button"
|
|
onClick={() =>
|
|
cancelRemoteDirectoryBrowse()
|
|
}
|
|
type="button"
|
|
>
|
|
{t(
|
|
'projectSwitcher.remote.directoryPicker.cancel'
|
|
)}
|
|
</button>
|
|
<button
|
|
className="primary-button"
|
|
disabled={
|
|
directoryPickerLoading ||
|
|
!remoteDirectoryListing
|
|
}
|
|
onClick={() => {
|
|
if (!remoteDirectoryListing) {
|
|
return
|
|
}
|
|
setRemoteRootPath(
|
|
remoteDirectoryListing.path
|
|
)
|
|
cancelRemoteDirectoryBrowse()
|
|
}}
|
|
type="button"
|
|
>
|
|
{t(
|
|
'projectSwitcher.remote.directoryPicker.select'
|
|
)}
|
|
</button>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
{error && (
|
|
<p className="project-create-card__error" role="alert">
|
|
{error}
|
|
</p>
|
|
)}
|
|
{dialogMode === 'settings' &&
|
|
settingsProject?.kind !== 'channel' && (
|
|
<section
|
|
aria-labelledby="project-danger-title"
|
|
className="project-danger-zone"
|
|
>
|
|
<div>
|
|
<strong id="project-danger-title">
|
|
{t('projectSwitcher.dialog.danger.title')}
|
|
</strong>
|
|
<p>
|
|
{t('projectSwitcher.dialog.danger.description')}
|
|
</p>
|
|
</div>
|
|
{!confirmingDelete ? (
|
|
<button
|
|
className="danger-button danger-button--quiet"
|
|
disabled={busy || userProjects.length <= 1}
|
|
onClick={() => {
|
|
setError(undefined)
|
|
setDeleteConfirmation('')
|
|
setConfirmingDelete(true)
|
|
}}
|
|
type="button"
|
|
>
|
|
<Trash2 size={13} />
|
|
{t('projectSwitcher.dialog.danger.delete')}
|
|
</button>
|
|
) : (
|
|
<div className="project-delete-confirmation">
|
|
<label>
|
|
<span>
|
|
{t(
|
|
'projectSwitcher.dialog.danger.confirmation',
|
|
{ projectName: settingsProject?.name }
|
|
)}
|
|
</span>
|
|
<input
|
|
autoFocus
|
|
disabled={busy}
|
|
onChange={(event) =>
|
|
setDeleteConfirmation(event.target.value)
|
|
}
|
|
value={deleteConfirmation}
|
|
/>
|
|
</label>
|
|
<div>
|
|
<button
|
|
className="secondary-button"
|
|
disabled={busy}
|
|
onClick={() => {
|
|
setError(undefined)
|
|
setDeleteConfirmation('')
|
|
setConfirmingDelete(false)
|
|
}}
|
|
type="button"
|
|
>
|
|
{t('projectSwitcher.dialog.danger.cancel')}
|
|
</button>
|
|
<button
|
|
className="danger-button"
|
|
disabled={
|
|
busy ||
|
|
deleteConfirmation !== settingsProject?.name
|
|
}
|
|
onClick={() => void deleteProject()}
|
|
type="button"
|
|
>
|
|
<Trash2 size={13} />
|
|
{deleting
|
|
? t(
|
|
'projectSwitcher.dialog.danger.deleting'
|
|
)
|
|
: t(
|
|
'projectSwitcher.dialog.danger.permanentlyDelete'
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{userProjects.length <= 1 && (
|
|
<small>
|
|
{t('projectSwitcher.dialog.danger.keepOne')}
|
|
</small>
|
|
)}
|
|
</section>
|
|
)}
|
|
</div>
|
|
<div className="project-create-card__actions">
|
|
{dialogMode === 'settings' &&
|
|
settingsProject?.kind !== 'channel' &&
|
|
userProjects.length > 1 &&
|
|
settingsProject && (
|
|
<button
|
|
className="secondary-button"
|
|
disabled={busy}
|
|
onClick={() => void archive()}
|
|
type="button"
|
|
>
|
|
<Archive size={13} />
|
|
{archiving
|
|
? t('projectSwitcher.dialog.archiving')
|
|
: t('projectSwitcher.dialog.archive')}
|
|
</button>
|
|
)}
|
|
<button
|
|
className="secondary-button"
|
|
disabled={archiving || deleting}
|
|
onClick={() => closeDialog()}
|
|
type="button"
|
|
>
|
|
{t('projectSwitcher.dialog.cancel')}
|
|
</button>
|
|
{executionSpaceKind === 'ssh' ? (
|
|
<button
|
|
className="primary-button"
|
|
disabled={
|
|
busy ||
|
|
confirmingDelete ||
|
|
!remoteDraftReady
|
|
}
|
|
onClick={() => void saveRemoteProject()}
|
|
type="button"
|
|
>
|
|
{remoteSaving
|
|
? t('projectSwitcher.remote.actions.saving')
|
|
: t('projectSwitcher.remote.actions.save')}
|
|
</button>
|
|
) : (
|
|
<button
|
|
className="primary-button"
|
|
disabled={
|
|
busy || !draft.name.trim() || confirmingDelete ||
|
|
(dialogMode === 'create' && !draft.rootPath.trim())
|
|
}
|
|
onClick={() => void save()}
|
|
type="button"
|
|
>
|
|
{saving
|
|
? dialogMode === 'create'
|
|
? t('projectSwitcher.dialog.creating')
|
|
: t('projectSwitcher.dialog.saving')
|
|
: dialogMode === 'create'
|
|
? t('projectSwitcher.dialog.create')
|
|
: t('projectSwitcher.dialog.save')}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>,
|
|
document.body
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/** Keep the hidden sidebar's project menus and activity rows out of the tree. */
|
|
const ProjectSwitcherWhenVisible = (props: ProjectSwitcherProps): React.JSX.Element | null =>
|
|
props.visible === false ? null : <ProjectSwitcherView {...props} />
|
|
|
|
/** Memoized: App re-renders on chat updates; this view re-renders only when its props change. */
|
|
export const ProjectSwitcher = memo(ProjectSwitcherWhenVisible)
|