* fix(settings): collapse Projects list to one row + pane per project (#8566) Settings and its nav enumerated repo rows, so a project set up on multiple execution hosts (local + a Remote Orca Server, or the same repo cloned on two machines) rendered two nav rows + two panes that collided (duplicate React key/DOM id) or mirrored. Derive the Settings list from the project projection (repos-only, deterministic) so nav, panes, and the Cmd+J palette all collapse to one entry per project. Deep-link repoId targets resolve to the project's representative section. * fix(settings): switch project host in place, host-scoped edits + deep links (#8566) Add an ephemeral per-project host selection driven by the pane's "Available Hosts" switcher, so the single collapsed pane shows the selected host's setup (path, worktree base, runtime, fork-sync, hooks, source-control AI). Route edits to the selected host by threading an optional hostId through updateRepo (mirrors removeProject's host routing), fixing the same-id/self-pair case where a local + runtime share one repo id. Couple Settings deep links to the switcher so host-specific subsection anchors resolve, load hooks for the selected host, and make pane-level Remove Project remove every host setup. * fix(settings): isolate selected project host state * fix(settings): review follow-ups for per-project Projects pane (#8566) - Remove Project copy now states it removes the project on all configured hosts (the button removes every host setup); new catalog key translated across all 5 locales - ensureHooksConfirmed forwards hostId to readRuntimeIssueCommand so the issue-command trust read resolves duplicate repo ids the same way as its sibling checkRuntimeHooks call - translate the multi-host "N hosts" nav description - extract removeSettingsProjectFromAllHosts with unit tests; drop the now-unused getRuntimeTargetIdentity
This commit is contained in:
parent
59a7fffcd6
commit
a5cea59933
|
|
@ -87,7 +87,12 @@ import type { RepoMethod } from '../../shared/telemetry-events'
|
|||
import { detectRepoIconAndUpstream } from '../repo-icon-autodetect'
|
||||
import { enrichMissingRepoGitRemoteIdentities } from '../repo-git-remote-identity-enrichment'
|
||||
import { getProjectHostSetupForRepo } from '../../shared/project-host-setup-projection'
|
||||
import { normalizeExecutionHostId, parseExecutionHostId } from '../../shared/execution-host'
|
||||
import {
|
||||
getRepoExecutionHostId,
|
||||
normalizeExecutionHostId,
|
||||
parseExecutionHostId,
|
||||
type ExecutionHostId
|
||||
} from '../../shared/execution-host'
|
||||
import { joinRemotePath } from '../ssh/ssh-remote-platform'
|
||||
import {
|
||||
assertFolderWorkspacePathUsable,
|
||||
|
|
@ -2430,8 +2435,11 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
|
|||
|
||||
ipcMain.handle(
|
||||
'repos:getBaseRefDefault',
|
||||
async (_event, args: { repoId: string }): Promise<BaseRefDefaultResult> => {
|
||||
const repo = store.getRepo(args.repoId)
|
||||
async (
|
||||
_event,
|
||||
args: { repoId: string; hostId?: ExecutionHostId }
|
||||
): Promise<BaseRefDefaultResult> => {
|
||||
const repo = getRepoForExecutionHost(store, args.repoId, args.hostId)
|
||||
if (!repo || isFolderRepo(repo)) {
|
||||
// Why: folder-mode repos have no git state to resolve a base ref from.
|
||||
// Return null + 0 so the renderer can decline to use a fabricated default
|
||||
|
|
@ -2507,14 +2515,20 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
|
|||
|
||||
ipcMain.handle(
|
||||
'repos:searchBaseRefs',
|
||||
async (_event, args: { repoId: string; query: string; limit?: number }) => {
|
||||
async (
|
||||
_event,
|
||||
args: { repoId: string; query: string; limit?: number; hostId?: ExecutionHostId }
|
||||
) => {
|
||||
return (await searchBaseRefDetailsForRepo(store, args)).map((entry) => entry.refName)
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
'repos:searchBaseRefDetails',
|
||||
async (_event, args: { repoId: string; query: string; limit?: number }) => {
|
||||
async (
|
||||
_event,
|
||||
args: { repoId: string; query: string; limit?: number; hostId?: ExecutionHostId }
|
||||
) => {
|
||||
return searchBaseRefDetailsForRepo(store, args)
|
||||
}
|
||||
)
|
||||
|
|
@ -2522,9 +2536,9 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
|
|||
|
||||
async function searchBaseRefDetailsForRepo(
|
||||
store: Store,
|
||||
args: { repoId: string; query: string; limit?: number }
|
||||
args: { repoId: string; query: string; limit?: number; hostId?: ExecutionHostId }
|
||||
): Promise<BaseRefSearchResult[]> {
|
||||
const repo = store.getRepo(args.repoId)
|
||||
const repo = getRepoForExecutionHost(store, args.repoId, args.hostId)
|
||||
if (!repo || isFolderRepo(repo)) {
|
||||
return []
|
||||
}
|
||||
|
|
@ -2602,6 +2616,23 @@ async function searchBaseRefDetailsForRepo(
|
|||
return searchBaseRefDetails(repo.path, args.query, limit)
|
||||
}
|
||||
|
||||
function getRepoForExecutionHost(
|
||||
store: Store,
|
||||
repoId: string,
|
||||
hostId?: ExecutionHostId
|
||||
): Repo | null {
|
||||
if (!hostId) {
|
||||
return store.getRepo(repoId) ?? null
|
||||
}
|
||||
// Why: repo ids can collide across local and SSH hosts; base-ref reads must
|
||||
// use the same host selected by the Settings pane as the subsequent write.
|
||||
return (
|
||||
store
|
||||
.getRepos()
|
||||
.find((repo) => repo.id === repoId && getRepoExecutionHostId(repo) === hostId) ?? null
|
||||
)
|
||||
}
|
||||
|
||||
function notifyReposChanged(mainWindow: BrowserWindow): void {
|
||||
if (!mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send('repos:changed')
|
||||
|
|
|
|||
|
|
@ -8428,6 +8428,45 @@ describe('registerWorktreeHandlers', () => {
|
|||
expect(fsProvider.writeFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reads an issue-command override from the requested host when repo ids collide', async () => {
|
||||
const localRepo = {
|
||||
id: 'repo-shared',
|
||||
path: '/local/repo',
|
||||
displayName: 'local',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0
|
||||
}
|
||||
const sshRepo = {
|
||||
...localRepo,
|
||||
path: '/remote/repo',
|
||||
displayName: 'ssh',
|
||||
connectionId: 'conn-1'
|
||||
}
|
||||
const fsProvider = {
|
||||
readFile: vi.fn(async (filePath: string) => {
|
||||
if (filePath.endsWith('/.orca/issue-command')) {
|
||||
return { content: 'remote command\n', isBinary: false }
|
||||
}
|
||||
throw Object.assign(new Error('missing'), { code: 'ENOENT' })
|
||||
})
|
||||
}
|
||||
store.getRepos.mockReturnValue([localRepo, sshRepo])
|
||||
store.getRepo.mockReturnValue(localRepo)
|
||||
getSshFilesystemProviderMock.mockReturnValue(fsProvider)
|
||||
|
||||
await expect(
|
||||
handlers['hooks:readIssueCommand'](null, {
|
||||
repoId: 'repo-shared',
|
||||
hostId: 'ssh:conn-1'
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
localContent: 'remote command',
|
||||
effectiveContent: 'remote command',
|
||||
source: 'local'
|
||||
})
|
||||
expect(fsProvider.readFile).toHaveBeenCalledWith('/remote/repo/.orca/issue-command')
|
||||
})
|
||||
|
||||
it('creates remote .gitignore only when it is missing while writing SSH issue commands', async () => {
|
||||
const repo = {
|
||||
id: 'repo-ssh',
|
||||
|
|
|
|||
|
|
@ -2248,74 +2248,77 @@ export function registerWorktreeHandlers(
|
|||
)
|
||||
})
|
||||
|
||||
ipcMain.handle('hooks:readIssueCommand', async (_event, args: { repoId: string }) => {
|
||||
const repo = store.getRepo(args.repoId)
|
||||
if (!repo || isFolderRepo(repo)) {
|
||||
return {
|
||||
status: 'ok',
|
||||
localContent: null,
|
||||
sharedContent: null,
|
||||
effectiveContent: null,
|
||||
localFilePath: '',
|
||||
source: 'none' as const
|
||||
}
|
||||
}
|
||||
if (repo.connectionId) {
|
||||
const issueCommandPath = joinWorktreeRelativePath(repo.path, '.orca/issue-command')
|
||||
const fsProvider = getSshFilesystemProvider(repo.connectionId)
|
||||
if (!fsProvider) {
|
||||
ipcMain.handle(
|
||||
'hooks:readIssueCommand',
|
||||
async (_event, args: { repoId: string; hostId?: ExecutionHostId }) => {
|
||||
const repo = getRepoForWorktreeRemoval(store, args.repoId, args.hostId)
|
||||
if (!repo || isFolderRepo(repo)) {
|
||||
return {
|
||||
status: 'error',
|
||||
status: 'ok',
|
||||
localContent: null,
|
||||
sharedContent: null,
|
||||
effectiveContent: null,
|
||||
localFilePath: issueCommandPath,
|
||||
localFilePath: '',
|
||||
source: 'none' as const
|
||||
}
|
||||
}
|
||||
if (repo.connectionId) {
|
||||
const issueCommandPath = joinWorktreeRelativePath(repo.path, '.orca/issue-command')
|
||||
const fsProvider = getSshFilesystemProvider(repo.connectionId)
|
||||
if (!fsProvider) {
|
||||
return {
|
||||
status: 'error',
|
||||
localContent: null,
|
||||
sharedContent: null,
|
||||
effectiveContent: null,
|
||||
localFilePath: issueCommandPath,
|
||||
source: 'none' as const
|
||||
}
|
||||
}
|
||||
|
||||
let status: 'ok' | 'error' = 'ok'
|
||||
let localContent: string | null = null
|
||||
let sharedContent: string | null = null
|
||||
try {
|
||||
const result = await fsProvider.readFile(issueCommandPath)
|
||||
localContent = result.isBinary ? null : result.content.trim() || null
|
||||
} catch (error) {
|
||||
if (!isENOENT(error)) {
|
||||
status = 'error'
|
||||
let status: 'ok' | 'error' = 'ok'
|
||||
let localContent: string | null = null
|
||||
let sharedContent: string | null = null
|
||||
try {
|
||||
const result = await fsProvider.readFile(issueCommandPath)
|
||||
localContent = result.isBinary ? null : result.content.trim() || null
|
||||
} catch (error) {
|
||||
if (!isENOENT(error)) {
|
||||
status = 'error'
|
||||
}
|
||||
}
|
||||
try {
|
||||
const result = await fsProvider.readFile(joinWorktreeRelativePath(repo.path, 'orca.yaml'))
|
||||
sharedContent = result.isBinary
|
||||
? null
|
||||
: parseOrcaYaml(result.content)?.issueCommand?.trim() || null
|
||||
} catch (error) {
|
||||
if (!isENOENT(error)) {
|
||||
status = 'error'
|
||||
}
|
||||
}
|
||||
const effectiveContent = localContent ?? sharedContent
|
||||
return {
|
||||
status: localContent ? 'ok' : status,
|
||||
localContent,
|
||||
sharedContent,
|
||||
effectiveContent,
|
||||
localFilePath: issueCommandPath,
|
||||
source: localContent
|
||||
? ('local' as const)
|
||||
: sharedContent
|
||||
? ('shared' as const)
|
||||
: ('none' as const)
|
||||
}
|
||||
}
|
||||
try {
|
||||
const result = await fsProvider.readFile(joinWorktreeRelativePath(repo.path, 'orca.yaml'))
|
||||
sharedContent = result.isBinary
|
||||
? null
|
||||
: parseOrcaYaml(result.content)?.issueCommand?.trim() || null
|
||||
} catch (error) {
|
||||
if (!isENOENT(error)) {
|
||||
status = 'error'
|
||||
}
|
||||
}
|
||||
const effectiveContent = localContent ?? sharedContent
|
||||
return {
|
||||
status: localContent ? 'ok' : status,
|
||||
localContent,
|
||||
sharedContent,
|
||||
effectiveContent,
|
||||
localFilePath: issueCommandPath,
|
||||
source: localContent
|
||||
? ('local' as const)
|
||||
: sharedContent
|
||||
? ('shared' as const)
|
||||
: ('none' as const)
|
||||
}
|
||||
return readIssueCommand(repo.path)
|
||||
}
|
||||
return readIssueCommand(repo.path)
|
||||
})
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
'hooks:writeIssueCommand',
|
||||
async (_event, args: { repoId: string; content: string }) => {
|
||||
const repo = store.getRepo(args.repoId)
|
||||
async (_event, args: { repoId: string; content: string; hostId?: ExecutionHostId }) => {
|
||||
const repo = getRepoForWorktreeRemoval(store, args.repoId, args.hostId)
|
||||
if (!repo || isFolderRepo(repo)) {
|
||||
return
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1020,12 +1020,21 @@ export type PreloadApi = {
|
|||
getDefaultCreateProjectParent: () => Promise<string>
|
||||
onCloneProgress: (callback: (data: { phase: string; percent: number }) => void) => () => void
|
||||
getGitUsername: (args: { repoId: string }) => Promise<string>
|
||||
getBaseRefDefault: (args: { repoId: string }) => Promise<BaseRefDefaultResult>
|
||||
searchBaseRefs: (args: { repoId: string; query: string; limit?: number }) => Promise<string[]>
|
||||
getBaseRefDefault: (args: {
|
||||
repoId: string
|
||||
hostId?: ExecutionHostId
|
||||
}) => Promise<BaseRefDefaultResult>
|
||||
searchBaseRefs: (args: {
|
||||
repoId: string
|
||||
query: string
|
||||
limit?: number
|
||||
hostId?: ExecutionHostId
|
||||
}) => Promise<string[]>
|
||||
searchBaseRefDetails: (args: {
|
||||
repoId: string
|
||||
query: string
|
||||
limit?: number
|
||||
hostId?: ExecutionHostId
|
||||
}) => Promise<BaseRefSearchResult[]>
|
||||
onChanged: (callback: () => void) => () => void
|
||||
}
|
||||
|
|
@ -2242,7 +2251,7 @@ export type PreloadApi = {
|
|||
worktreePath: string
|
||||
command: string
|
||||
}) => Promise<WorktreeSetupLaunch>
|
||||
readIssueCommand: (args: { repoId: string }) => Promise<{
|
||||
readIssueCommand: (args: { repoId: string; hostId?: ExecutionHostId }) => Promise<{
|
||||
status?: 'ok' | 'error'
|
||||
localContent: string | null
|
||||
sharedContent: string | null
|
||||
|
|
@ -2250,7 +2259,11 @@ export type PreloadApi = {
|
|||
localFilePath: string
|
||||
source: 'local' | 'shared' | 'none'
|
||||
}>
|
||||
writeIssueCommand: (args: { repoId: string; content: string }) => Promise<void>
|
||||
writeIssueCommand: (args: {
|
||||
repoId: string
|
||||
content: string
|
||||
hostId?: ExecutionHostId
|
||||
}) => Promise<void>
|
||||
}
|
||||
ephemeralVm: {
|
||||
listRecipes: (args: { repoId: string }) => Promise<{
|
||||
|
|
|
|||
|
|
@ -598,16 +598,23 @@ const api = {
|
|||
getGitUsername: (args: { repoId: string }): Promise<string> =>
|
||||
ipcRenderer.invoke('repos:getGitUsername', args),
|
||||
|
||||
getBaseRefDefault: (args: { repoId: string }): Promise<BaseRefDefaultResult> =>
|
||||
ipcRenderer.invoke('repos:getBaseRefDefault', args),
|
||||
getBaseRefDefault: (args: {
|
||||
repoId: string
|
||||
hostId?: ExecutionHostId
|
||||
}): Promise<BaseRefDefaultResult> => ipcRenderer.invoke('repos:getBaseRefDefault', args),
|
||||
|
||||
searchBaseRefs: (args: { repoId: string; query: string; limit?: number }): Promise<string[]> =>
|
||||
ipcRenderer.invoke('repos:searchBaseRefs', args),
|
||||
searchBaseRefs: (args: {
|
||||
repoId: string
|
||||
query: string
|
||||
limit?: number
|
||||
hostId?: ExecutionHostId
|
||||
}): Promise<string[]> => ipcRenderer.invoke('repos:searchBaseRefs', args),
|
||||
|
||||
searchBaseRefDetails: (args: {
|
||||
repoId: string
|
||||
query: string
|
||||
limit?: number
|
||||
hostId?: ExecutionHostId
|
||||
}): Promise<BaseRefSearchResult[]> => ipcRenderer.invoke('repos:searchBaseRefDetails', args),
|
||||
|
||||
onChanged: (callback: () => void): (() => void) => {
|
||||
|
|
@ -2639,6 +2646,7 @@ const api = {
|
|||
|
||||
readIssueCommand: (args: {
|
||||
repoId: string
|
||||
hostId?: ExecutionHostId
|
||||
}): Promise<{
|
||||
status?: 'ok' | 'error'
|
||||
localContent: string | null
|
||||
|
|
@ -2648,8 +2656,11 @@ const api = {
|
|||
source: 'local' | 'shared' | 'none'
|
||||
}> => ipcRenderer.invoke('hooks:readIssueCommand', args),
|
||||
|
||||
writeIssueCommand: (args: { repoId: string; content: string }): Promise<void> =>
|
||||
ipcRenderer.invoke('hooks:writeIssueCommand', args)
|
||||
writeIssueCommand: (args: {
|
||||
repoId: string
|
||||
content: string
|
||||
hostId?: ExecutionHostId
|
||||
}): Promise<void> => ipcRenderer.invoke('hooks:writeIssueCommand', args)
|
||||
},
|
||||
|
||||
ephemeralVm: {
|
||||
|
|
|
|||
|
|
@ -10,9 +10,11 @@ import {
|
|||
} from '@/runtime/runtime-repo-client'
|
||||
import { isRuntimeRepoRefSearchQueryWithinLimit } from '@/runtime/runtime-repo-search-bounds'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { parseExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host'
|
||||
|
||||
type BaseRefPickerProps = {
|
||||
repoId: string
|
||||
hostId?: ExecutionHostId
|
||||
currentBaseRef?: string
|
||||
onSelect: (ref: string) => void
|
||||
onUsePrimary?: () => void
|
||||
|
|
@ -20,13 +22,20 @@ type BaseRefPickerProps = {
|
|||
|
||||
export function BaseRefPicker({
|
||||
repoId,
|
||||
hostId,
|
||||
currentBaseRef,
|
||||
onSelect,
|
||||
onUsePrimary
|
||||
}: BaseRefPickerProps): React.JSX.Element {
|
||||
const activeRuntimeEnvironmentId = useAppStore((state) =>
|
||||
const focusedRuntimeEnvironmentId = useAppStore((state) =>
|
||||
getRuntimeEnvironmentIdForRepo(state, repoId)
|
||||
)
|
||||
const selectedHost = parseExecutionHostId(hostId)
|
||||
const activeRuntimeEnvironmentId = hostId
|
||||
? selectedHost?.kind === 'runtime'
|
||||
? selectedHost.environmentId
|
||||
: null
|
||||
: focusedRuntimeEnvironmentId
|
||||
// Why: null until the IPC resolves (or when the repo has no default base ref
|
||||
// available). We avoid seeding with 'origin/main' because that would display
|
||||
// a fabricated default in repos that don't actually have origin/main.
|
||||
|
|
@ -64,7 +73,11 @@ export function BaseRefPicker({
|
|||
|
||||
const loadDefaultBaseRef = async (): Promise<void> => {
|
||||
try {
|
||||
const result = await getRuntimeRepoBaseRefDefault({ activeRuntimeEnvironmentId }, repoId)
|
||||
const result = await getRuntimeRepoBaseRefDefault(
|
||||
{ activeRuntimeEnvironmentId },
|
||||
repoId,
|
||||
hostId
|
||||
)
|
||||
if (!stale) {
|
||||
setDefaultBaseRef(result.defaultBaseRef)
|
||||
setRemoteCount(result.remoteCount)
|
||||
|
|
@ -90,7 +103,7 @@ export function BaseRefPicker({
|
|||
return () => {
|
||||
stale = true
|
||||
}
|
||||
}, [activeRuntimeEnvironmentId, repoId])
|
||||
}, [activeRuntimeEnvironmentId, hostId, repoId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isRuntimeRepoRefSearchQueryWithinLimit(baseRefQuery)) {
|
||||
|
|
@ -109,7 +122,13 @@ export function BaseRefPicker({
|
|||
setIsSearchingBaseRefs(true)
|
||||
|
||||
const timer = window.setTimeout(() => {
|
||||
void searchRuntimeRepoBaseRefs({ activeRuntimeEnvironmentId }, repoId, trimmedQuery, 20)
|
||||
void searchRuntimeRepoBaseRefs(
|
||||
{ activeRuntimeEnvironmentId },
|
||||
repoId,
|
||||
trimmedQuery,
|
||||
20,
|
||||
hostId
|
||||
)
|
||||
.then((results) => {
|
||||
if (!stale) {
|
||||
setBaseRefResults(results)
|
||||
|
|
@ -132,7 +151,7 @@ export function BaseRefPicker({
|
|||
stale = true
|
||||
window.clearTimeout(timer)
|
||||
}
|
||||
}, [activeRuntimeEnvironmentId, baseRefQuery, repoId])
|
||||
}, [activeRuntimeEnvironmentId, baseRefQuery, hostId, repoId])
|
||||
|
||||
const effectiveBaseRef = currentBaseRef ?? defaultBaseRef
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
/* eslint-disable max-lines -- Why: the script editor, advanced/Command Source disclosure, issue-command override, and YAML state surfaces share tightly coupled state and persistence; splitting them across files would scatter prop drilling. */
|
||||
/* oxlint-disable react-doctor/no-adjust-state-on-prop-change -- Why: repository hook saves and issue-command overrides synchronize debounced persistence state with external repo settings. */
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type {
|
||||
HookCommandSourcePolicy,
|
||||
OrcaHooks,
|
||||
|
|
@ -24,6 +24,7 @@ import { getRepositoryLocalCommandsSectionId } from './repository-settings-targe
|
|||
import { matchesSettingsSearch } from './settings-search'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { getRepositoryHookScriptTextareaRows } from '@/lib/script-textarea-rows'
|
||||
import { getRepoExecutionHostId, parseExecutionHostId } from '../../../../shared/execution-host'
|
||||
|
||||
type RepositoryHooksSectionProps = {
|
||||
repo: Repo
|
||||
|
|
@ -749,8 +750,15 @@ export function RepositoryHooksSection({
|
|||
// Why: this component uses the lightweight translate() helper; subscribe here
|
||||
// so render-time option/copy builders refresh when the UI language changes.
|
||||
useTranslation()
|
||||
const settings = useAppStore((s) => s.settings)
|
||||
const settingsSearchQuery = useAppStore((s) => s.settingsSearchQuery)
|
||||
const selectedHostId = getRepoExecutionHostId(repo)
|
||||
const repoHostIdentity = `${selectedHostId}\0${repo.id}`
|
||||
const hookRuntimeSettings = useMemo(() => {
|
||||
const parsedHost = parseExecutionHostId(selectedHostId)
|
||||
return {
|
||||
activeRuntimeEnvironmentId: parsedHost?.kind === 'runtime' ? parsedHost.environmentId : null
|
||||
}
|
||||
}, [selectedHostId])
|
||||
const yamlState = yamlHooks
|
||||
? 'loaded'
|
||||
: hasHooksFile
|
||||
|
|
@ -764,7 +772,7 @@ export function RepositoryHooksSection({
|
|||
)
|
||||
const hookSettingsDraftRef = useRef(hookSettingsDraft)
|
||||
hookSettingsDraftRef.current = hookSettingsDraft
|
||||
const localCommandsRepoIdRef = useRef(repo.id)
|
||||
const localCommandsRepoIdentityRef = useRef(repoHostIdentity)
|
||||
const localCommandsDraftDirtyRef = useRef(false)
|
||||
const localCommandsAutosaveTimerRef = useRef<number | null>(null)
|
||||
const persistRef = useRef(onUpdateHookSettings)
|
||||
|
|
@ -878,7 +886,7 @@ export function RepositoryHooksSection({
|
|||
// dirty draft through the previous repo's captured updater.
|
||||
useEffect(() => {
|
||||
const next = getHookSettingsDraft(repo.hookSettings)
|
||||
const isSameRepo = localCommandsRepoIdRef.current === repo.id
|
||||
const isSameRepo = localCommandsRepoIdentityRef.current === repoHostIdentity
|
||||
|
||||
if (isSameRepo) {
|
||||
localCommandsPersistForRepoRef.current = onUpdateHookSettings
|
||||
|
|
@ -889,11 +897,17 @@ export function RepositoryHooksSection({
|
|||
}
|
||||
|
||||
flushScriptDraft(localCommandsPersistForRepoRef.current)
|
||||
localCommandsRepoIdRef.current = repo.id
|
||||
localCommandsRepoIdentityRef.current = repoHostIdentity
|
||||
localCommandsPersistForRepoRef.current = onUpdateHookSettings
|
||||
hookSettingsDraftRef.current = next
|
||||
setHookSettingsDraft(next)
|
||||
}, [flushScriptDraft, onUpdateHookSettings, repo.id, repo.hookSettings, syncHookSettingsDraft])
|
||||
}, [
|
||||
flushScriptDraft,
|
||||
onUpdateHookSettings,
|
||||
repo.hookSettings,
|
||||
repoHostIdentity,
|
||||
syncHookSettingsDraft
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
|
@ -903,7 +917,9 @@ export function RepositoryHooksSection({
|
|||
setHasSharedIssueCommand(false)
|
||||
setIssueCommandSaveError(null)
|
||||
|
||||
void readRuntimeIssueCommand(settings, repoId)
|
||||
// Why: the pane can show a host other than the globally focused runtime;
|
||||
// route both runtime RPC and local/SSH IPC by the selected repo owner.
|
||||
void readRuntimeIssueCommand(hookRuntimeSettings, repoId, selectedHostId)
|
||||
.then((result) => {
|
||||
if (cancelled) {
|
||||
return
|
||||
|
|
@ -925,18 +941,20 @@ export function RepositoryHooksSection({
|
|||
cancelled = true
|
||||
const draft = issueCommandDraftRef.current.trim()
|
||||
if (draft !== lastCommittedIssueCommandRef.current) {
|
||||
void writeRuntimeIssueCommand(settings, repoId, draft).catch((err) => {
|
||||
console.error('[RepositoryHooksSection] Failed to save issue command on unmount:', err)
|
||||
})
|
||||
void writeRuntimeIssueCommand(hookRuntimeSettings, repoId, draft, selectedHostId).catch(
|
||||
(err) => {
|
||||
console.error('[RepositoryHooksSection] Failed to save issue command on unmount:', err)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}, [repo.id, settings])
|
||||
}, [hookRuntimeSettings, repo.id, repoHostIdentity, selectedHostId])
|
||||
|
||||
const commitIssueCommand = useCallback(async (): Promise<void> => {
|
||||
const trimmed = issueCommandDraft.trim()
|
||||
setIssueCommandDraft(trimmed)
|
||||
try {
|
||||
await writeRuntimeIssueCommand(settings, repo.id, trimmed)
|
||||
await writeRuntimeIssueCommand(hookRuntimeSettings, repo.id, trimmed, selectedHostId)
|
||||
lastCommittedIssueCommandRef.current = trimmed
|
||||
setIssueCommandSaveError(null)
|
||||
} catch (err) {
|
||||
|
|
@ -945,7 +963,7 @@ export function RepositoryHooksSection({
|
|||
setIssueCommandSaveError(message)
|
||||
toast.error(message)
|
||||
}
|
||||
}, [issueCommandDraft, repo.id, settings])
|
||||
}, [hookRuntimeSettings, issueCommandDraft, repo.id, selectedHostId])
|
||||
|
||||
const sharedSetupScript = yamlHooks?.scripts.setup
|
||||
const sharedArchiveScript = yamlHooks?.scripts.archive
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ type RepositoryHostSetupActionsProps = {
|
|||
setupState: 'not-set-up'
|
||||
setupMethod: 'provisioned'
|
||||
}) => Promise<ProjectHostSetupCreateResult | null>
|
||||
onOpenSetup: (repoId: string) => void
|
||||
onSetupReady: (hostId: ExecutionHostId) => void
|
||||
}
|
||||
|
||||
type SetupStep = 'choose' | 'existing' | 'clone' | 'planned'
|
||||
|
|
@ -55,7 +55,7 @@ export function RepositoryHostSetupActions({
|
|||
setupProjectExistingFolder,
|
||||
setupProjectClone,
|
||||
createProjectHostSetup,
|
||||
onOpenSetup
|
||||
onSetupReady
|
||||
}: RepositoryHostSetupActionsProps): React.JSX.Element | null {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [step, setStep] = useState<SetupStep>('choose')
|
||||
|
|
@ -102,7 +102,7 @@ export function RepositoryHostSetupActions({
|
|||
})
|
||||
if (result) {
|
||||
resetFlow()
|
||||
onOpenSetup(result.repo.id)
|
||||
onSetupReady(setupTargetHostId)
|
||||
}
|
||||
} finally {
|
||||
setIsSettingUp(false)
|
||||
|
|
@ -129,7 +129,7 @@ export function RepositoryHostSetupActions({
|
|||
})
|
||||
if (result) {
|
||||
resetFlow()
|
||||
onOpenSetup(result.repo.id)
|
||||
onSetupReady(setupTargetHostId)
|
||||
}
|
||||
} finally {
|
||||
setIsCloning(false)
|
||||
|
|
|
|||
|
|
@ -147,9 +147,10 @@ describe('RepositoryHostSetupsSection', () => {
|
|||
expect(container.textContent).toContain(LOCAL_HOST_LABEL)
|
||||
})
|
||||
|
||||
it('opens the selected host setup settings pane through the setup repo id', () => {
|
||||
it('selects the host in place instead of navigating to a separate repo pane', () => {
|
||||
const openSettingsPage = vi.fn()
|
||||
const openSettingsTarget = vi.fn()
|
||||
const setSettingsProjectHostSelection = vi.fn()
|
||||
const localRepo = makeRepo({
|
||||
id: 'local-repo',
|
||||
displayName: 'Orca',
|
||||
|
|
@ -181,7 +182,8 @@ describe('RepositoryHostSetupsSection', () => {
|
|||
})
|
||||
],
|
||||
openSettingsPage,
|
||||
openSettingsTarget
|
||||
openSettingsTarget,
|
||||
setSettingsProjectHostSelection
|
||||
})
|
||||
|
||||
renderSection(localRepo)
|
||||
|
|
@ -196,8 +198,13 @@ describe('RepositoryHostSetupsSection', () => {
|
|||
openButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
||||
})
|
||||
|
||||
expect(openSettingsPage).toHaveBeenCalledTimes(1)
|
||||
expect(openSettingsTarget).toHaveBeenCalledWith({ pane: 'repo', repoId: 'remote-repo' })
|
||||
// The single project pane switches host in place — no navigation.
|
||||
expect(setSettingsProjectHostSelection).toHaveBeenCalledWith(
|
||||
'github:stablyai/orca',
|
||||
toSshExecutionHostId('openclaw 2')
|
||||
)
|
||||
expect(openSettingsPage).not.toHaveBeenCalled()
|
||||
expect(openSettingsTarget).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('removes independent setup metadata instead of opening an empty repo target', async () => {
|
||||
|
|
@ -264,6 +271,7 @@ describe('RepositoryHostSetupsSection', () => {
|
|||
it('sets up the project on another known host from an existing folder path', async () => {
|
||||
const openSettingsPage = vi.fn()
|
||||
const openSettingsTarget = vi.fn()
|
||||
const setSettingsProjectHostSelection = vi.fn()
|
||||
const setupProjectExistingFolder = vi.fn().mockResolvedValue({
|
||||
project: makeProject({ id: 'github:stablyai/orca' }),
|
||||
setup: makeSetup({
|
||||
|
|
@ -300,6 +308,7 @@ describe('RepositoryHostSetupsSection', () => {
|
|||
sshTargetLabels: new Map([['openclaw 2', 'openclaw 2']]),
|
||||
openSettingsPage,
|
||||
openSettingsTarget,
|
||||
setSettingsProjectHostSelection,
|
||||
setupProjectExistingFolder
|
||||
})
|
||||
|
||||
|
|
@ -327,13 +336,18 @@ describe('RepositoryHostSetupsSection', () => {
|
|||
kind: 'git',
|
||||
displayName: 'Orca'
|
||||
})
|
||||
expect(openSettingsPage).toHaveBeenCalledTimes(1)
|
||||
expect(openSettingsTarget).toHaveBeenCalledWith({ pane: 'repo', repoId: 'remote-repo' })
|
||||
expect(setSettingsProjectHostSelection).toHaveBeenCalledWith(
|
||||
'github:stablyai/orca',
|
||||
'ssh:openclaw%202'
|
||||
)
|
||||
expect(openSettingsPage).not.toHaveBeenCalled()
|
||||
expect(openSettingsTarget).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('clones the project onto another known host from settings', async () => {
|
||||
const openSettingsPage = vi.fn()
|
||||
const openSettingsTarget = vi.fn()
|
||||
const setSettingsProjectHostSelection = vi.fn()
|
||||
const setupProjectClone = vi.fn().mockResolvedValue({
|
||||
project: makeProject({ id: 'github:stablyai/orca' }),
|
||||
setup: makeSetup({
|
||||
|
|
@ -370,6 +384,7 @@ describe('RepositoryHostSetupsSection', () => {
|
|||
sshTargetLabels: new Map([['openclaw 2', 'openclaw 2']]),
|
||||
openSettingsPage,
|
||||
openSettingsTarget,
|
||||
setSettingsProjectHostSelection,
|
||||
setupProjectClone
|
||||
})
|
||||
|
||||
|
|
@ -402,8 +417,12 @@ describe('RepositoryHostSetupsSection', () => {
|
|||
destination: '/home/alice',
|
||||
displayName: 'Orca'
|
||||
})
|
||||
expect(openSettingsPage).toHaveBeenCalledTimes(1)
|
||||
expect(openSettingsTarget).toHaveBeenCalledWith({ pane: 'repo', repoId: 'remote-repo' })
|
||||
expect(setSettingsProjectHostSelection).toHaveBeenCalledWith(
|
||||
'github:stablyai/orca',
|
||||
'ssh:openclaw%202'
|
||||
)
|
||||
expect(openSettingsPage).not.toHaveBeenCalled()
|
||||
expect(openSettingsTarget).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('creates pending setup metadata for a known host without requiring a path', async () => {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
import { useMemo, useState } from 'react'
|
||||
import { getExecutionHostLabel } from '../../../../shared/execution-host'
|
||||
import {
|
||||
getExecutionHostLabel,
|
||||
getRepoExecutionHostId,
|
||||
type ExecutionHostId
|
||||
} from '../../../../shared/execution-host'
|
||||
import { buildExecutionHostRegistry } from '../../../../shared/execution-host-registry'
|
||||
import { getHostDisplayLabelOverrides } from '../../../../shared/host-setting-overrides'
|
||||
import type { Repo } from '../../../../shared/types'
|
||||
|
|
@ -30,8 +34,9 @@ export function RepositoryHostSetupsSection({
|
|||
searchQuery,
|
||||
searchEntries
|
||||
}: RepositoryHostSetupsSectionProps): React.JSX.Element | null {
|
||||
const openSettingsPage = useAppStore((state) => state.openSettingsPage)
|
||||
const openSettingsTarget = useAppStore((state) => state.openSettingsTarget)
|
||||
const setSettingsProjectHostSelection = useAppStore(
|
||||
(state) => state.setSettingsProjectHostSelection
|
||||
)
|
||||
const setupProjectExistingFolder = useAppStore((state) => state.setupProjectExistingFolder)
|
||||
const setupProjectClone = useAppStore((state) => state.setupProjectClone)
|
||||
const createProjectHostSetup = useAppStore((state) => state.createProjectHostSetup)
|
||||
|
|
@ -67,8 +72,9 @@ export function RepositoryHostSetupsSection({
|
|||
const projectHostSetupProjection = useAppStore((state) =>
|
||||
getProjectHostSetupProjectionFromState(state)
|
||||
)
|
||||
const selectedHostId = getRepoExecutionHostId(repo)
|
||||
const selectedProjectHostSetup = projectHostSetupProjection.setups.find(
|
||||
(setup) => setup.repoId === repo.id
|
||||
(setup) => setup.repoId === repo.id && setup.hostId === selectedHostId
|
||||
)
|
||||
const projectHostSetups = selectedProjectHostSetup
|
||||
? projectHostSetupProjection.setups.filter(
|
||||
|
|
@ -82,11 +88,14 @@ export function RepositoryHostSetupsSection({
|
|||
})
|
||||
const hostOptionById = new Map(hostOptions.map((option) => [option.id, option]))
|
||||
const [deletingSetupId, setDeletingSetupId] = useState<string | null>(null)
|
||||
const openSetup = (repoId: string) => {
|
||||
openSettingsPage()
|
||||
openSettingsTarget({ pane: 'repo', repoId })
|
||||
const projectId = selectedProjectHostSetup?.projectId
|
||||
// Why: the single project pane switches host in place — set the ephemeral
|
||||
// per-project selection instead of navigating to a separate repo section.
|
||||
const selectHost = (hostId: ExecutionHostId) => {
|
||||
if (projectId) {
|
||||
setSettingsProjectHostSelection(projectId, hostId)
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
(projectHostSetups.length <= 1 && setupHostOptions.length === 0) ||
|
||||
(!forceVisible && !matchesSettingsSearch(searchQuery, searchEntries))
|
||||
|
|
@ -116,12 +125,12 @@ export function RepositoryHostSetupsSection({
|
|||
{translate('auto.components.settings.RepositoryPane.viewingHost', 'Viewing host')}
|
||||
</span>
|
||||
<Select
|
||||
value={repo.id}
|
||||
onValueChange={(repoId) => {
|
||||
if (repoId === repo.id) {
|
||||
value={selectedHostId}
|
||||
onValueChange={(hostId) => {
|
||||
if (hostId === selectedHostId) {
|
||||
return
|
||||
}
|
||||
openSetup(repoId)
|
||||
selectHost(hostId as ExecutionHostId)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-44 min-w-0 text-xs">
|
||||
|
|
@ -129,7 +138,7 @@ export function RepositoryHostSetupsSection({
|
|||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{openableProjectHostSetups.map((setup) => (
|
||||
<SelectItem key={setup.id} value={setup.repoId}>
|
||||
<SelectItem key={setup.hostId} value={setup.hostId}>
|
||||
<span className="block min-w-0 truncate">
|
||||
{hostOptionById.get(setup.hostId)?.label ??
|
||||
getExecutionHostLabel(setup.hostId)}
|
||||
|
|
@ -150,12 +159,12 @@ export function RepositoryHostSetupsSection({
|
|||
</div>
|
||||
<div className="divide-y divide-border rounded-md border border-border">
|
||||
{projectHostSetups.map((setup) => {
|
||||
const isCurrentSetup = setup.repoId === repo.id
|
||||
const isCurrentSetup = setup.hostId === selectedHostId
|
||||
const canOpenSetup = setup.repoId.trim().length > 0
|
||||
const canRemoveSetup = !canOpenSetup && deletingSetupId !== setup.id
|
||||
return (
|
||||
<div
|
||||
key={setup.id}
|
||||
key={setup.hostId}
|
||||
className={cn(
|
||||
'flex w-full items-start gap-3 px-3 py-2.5 text-left transition-colors',
|
||||
isCurrentSetup ? 'bg-muted/30' : ''
|
||||
|
|
@ -189,7 +198,7 @@ export function RepositoryHostSetupsSection({
|
|||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
openSetup(setup.repoId)
|
||||
selectHost(setup.hostId)
|
||||
}}
|
||||
>
|
||||
{translate('auto.components.settings.RepositoryPane.openSetup', 'Open')}
|
||||
|
|
@ -221,7 +230,7 @@ export function RepositoryHostSetupsSection({
|
|||
setupProjectExistingFolder={setupProjectExistingFolder}
|
||||
setupProjectClone={setupProjectClone}
|
||||
createProjectHostSetup={createProjectHostSetup}
|
||||
onOpenSetup={openSetup}
|
||||
onSetupReady={selectHost}
|
||||
/>
|
||||
) : null}
|
||||
</SearchableSetting>
|
||||
|
|
|
|||
|
|
@ -8,9 +8,8 @@ import { normalizeRepoBadgeColor } from '../../../../shared/repo-badge-color'
|
|||
import { Button } from '../ui/button'
|
||||
import { Label } from '../ui/label'
|
||||
import { RepoIconGlyph, getRepoLucideIconOptions } from '../repo/repo-icon'
|
||||
import { useAppStore } from '@/store'
|
||||
import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client'
|
||||
import { getRuntimeEnvironmentIdForRepo } from '@/lib/repo-runtime-owner'
|
||||
import { getRepoExecutionHostId, parseExecutionHostId } from '../../../../shared/execution-host'
|
||||
import { useMountedRef } from '@/hooks/useMountedRef'
|
||||
import { RepositoryIconColorSection } from './RepositoryIconColorSection'
|
||||
import { RepositoryIconTabs } from './RepositoryIconTabs'
|
||||
|
|
@ -33,9 +32,9 @@ export function RepositoryIconPicker({
|
|||
const mountedRef = useMountedRef()
|
||||
// Why: resolve this repo's upstream/avatar on the host that owns it, not the
|
||||
// focused runtime.
|
||||
const activeRuntimeEnvironmentId = useAppStore((state) =>
|
||||
getRuntimeEnvironmentIdForRepo(state, repo.id)
|
||||
)
|
||||
const selectedHost = parseExecutionHostId(getRepoExecutionHostId(repo))
|
||||
const activeRuntimeEnvironmentId =
|
||||
selectedHost?.kind === 'runtime' ? selectedHost.environmentId : null
|
||||
const selectedLucideName = repo.repoIcon?.type === 'lucide' ? repo.repoIcon.name : null
|
||||
const selectedEmoji = repo.repoIcon?.type === 'emoji' ? repo.repoIcon.emoji : ''
|
||||
const selectedBadgeColor = normalizeRepoBadgeColor(repo.badgeColor) ?? DEFAULT_REPO_BADGE_COLOR
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import type {
|
|||
RepoHookSettings
|
||||
} from '../../../../shared/types'
|
||||
import { getRepoKindLabel, isFolderRepo } from '../../../../shared/repo-kind'
|
||||
import { getRepoExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host'
|
||||
import { Button } from '../ui/button'
|
||||
import { Label } from '../ui/label'
|
||||
import { Separator } from '../ui/separator'
|
||||
|
|
@ -47,7 +48,11 @@ type RepositoryPaneProps = {
|
|||
hasHooksFile: boolean
|
||||
hooksInspectionReady: boolean
|
||||
mayNeedUpdate: boolean
|
||||
updateRepo: (repoId: string, updates: RepositoryPaneRepoUpdate) => void
|
||||
updateRepo: (
|
||||
repoId: string,
|
||||
updates: RepositoryPaneRepoUpdate,
|
||||
options?: { hostId?: ExecutionHostId }
|
||||
) => void
|
||||
removeProject: (repoId: string) => void
|
||||
project?: Project | null
|
||||
isLocalWindowsProject?: boolean
|
||||
|
|
@ -76,6 +81,16 @@ export function RepositoryPane({
|
|||
updateProject
|
||||
}: RepositoryPaneProps): React.JSX.Element {
|
||||
const isFolder = isFolderRepo(repo)
|
||||
// Why: this pane renders the switcher-selected host's repo row. Bind every
|
||||
// edit to that host so identity/host-specific writes land on the selected
|
||||
// host, not findRepoForHost's focused-host fallback (the same-id/self-pair
|
||||
// case where local and a runtime share one repo id).
|
||||
const selectedHostId = getRepoExecutionHostId(repo)
|
||||
const updateSelectedRepo = useCallback(
|
||||
(repoId: string, updates: RepositoryPaneRepoUpdate) =>
|
||||
updateRepo(repoId, updates, { hostId: selectedHostId }),
|
||||
[updateRepo, selectedHostId]
|
||||
)
|
||||
const searchQuery = useAppStore((state) => state.settingsSearchQuery)
|
||||
const settings = useAppStore((state) => state.settings)
|
||||
const runtimeSessionSummary = useAppStore(
|
||||
|
|
@ -119,7 +134,7 @@ export function RepositoryPane({
|
|||
}
|
||||
|
||||
const updateSelectedRepoHookSettings = (nextSettings: RepoHookSettings) => {
|
||||
updateRepo(repo.id, {
|
||||
updateSelectedRepo(repo.id, {
|
||||
hookSettings: nextSettings
|
||||
})
|
||||
}
|
||||
|
|
@ -229,8 +244,8 @@ export function RepositoryPane({
|
|||
'Remove Project'
|
||||
)}
|
||||
description={translate(
|
||||
'auto.components.settings.RepositoryPane.170624bdfb',
|
||||
'Remove this project from Orca.'
|
||||
'auto.components.settings.RepositoryPane.removeProjectAllHosts',
|
||||
'Remove this project from Orca on all configured hosts.'
|
||||
)}
|
||||
keywords={[repo.displayName, 'delete', 'project', 'repository']}
|
||||
className="absolute top-0 right-0 z-10 w-auto max-w-none"
|
||||
|
|
@ -273,7 +288,7 @@ export function RepositoryPane({
|
|||
id={`repo-display-name-${repo.id}`}
|
||||
repoId={repo.id}
|
||||
storeValue={repo.displayName}
|
||||
onTextChange={(text) => updateRepo(repo.id, { displayName: text })}
|
||||
onTextChange={(text) => updateSelectedRepo(repo.id, { displayName: text })}
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
</SearchableSetting>
|
||||
|
|
@ -298,7 +313,7 @@ export function RepositoryPane({
|
|||
id={getRepositoryIconSectionId(repo.id)}
|
||||
forceVisible={forceFullPaneForRepoMatch}
|
||||
>
|
||||
<RepositoryIconPicker repo={repo} updateRepo={updateRepo} />
|
||||
<RepositoryIconPicker repo={repo} updateRepo={updateSelectedRepo} />
|
||||
</SearchableSetting>
|
||||
|
||||
{!isFolder ? (
|
||||
|
|
@ -327,14 +342,14 @@ export function RepositoryPane({
|
|||
|
||||
<RepositoryForkSyncSection
|
||||
repo={repo}
|
||||
updateRepo={updateRepo}
|
||||
updateRepo={updateSelectedRepo}
|
||||
forceVisible={forceFullPaneForRepoMatch}
|
||||
/>
|
||||
|
||||
<RepositoryWorktreeDefaultsSection
|
||||
repo={repo}
|
||||
settings={settings}
|
||||
updateRepo={updateRepo}
|
||||
updateRepo={updateSelectedRepo}
|
||||
forceVisible={forceFullPaneForRepoMatch}
|
||||
/>
|
||||
</>
|
||||
|
|
@ -347,13 +362,13 @@ export function RepositoryPane({
|
|||
<RepositorySourceControlAiSection
|
||||
key="source-control-ai"
|
||||
repo={repo}
|
||||
updateRepo={updateRepo}
|
||||
updateRepo={updateSelectedRepo}
|
||||
/>
|
||||
) : null,
|
||||
!isFolder &&
|
||||
!repo.connectionId &&
|
||||
(forceFullPaneForRepoMatch || matchesSettingsSearch(searchQuery, symlinkEntries)) ? (
|
||||
<WorktreeSymlinksSection key="symlinks" repo={repo} updateRepo={updateRepo} />
|
||||
<WorktreeSymlinksSection key="symlinks" repo={repo} updateRepo={updateSelectedRepo} />
|
||||
) : null,
|
||||
!isFolder &&
|
||||
(forceFullPaneForRepoMatch || matchesSettingsSearch(searchQuery, sparsePresetEntries)) ? (
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { BaseRefPicker } from './BaseRefPicker'
|
|||
import { RepoSettingsDraftInput } from './RepositorySettingsDraftInput'
|
||||
import { SearchableSetting } from './SearchableSetting'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { getRepoExecutionHostId } from '../../../../shared/execution-host'
|
||||
|
||||
type RepositoryWorktreeDefaultsUpdate = Pick<Repo, 'worktreeBasePath' | 'worktreeBaseRef'>
|
||||
|
||||
|
|
@ -41,6 +42,7 @@ export function RepositoryWorktreeDefaultsSection({
|
|||
</Label>
|
||||
<BaseRefPicker
|
||||
repoId={repo.id}
|
||||
hostId={getRepoExecutionHostId(repo)}
|
||||
currentBaseRef={repo.worktreeBaseRef}
|
||||
onSelect={(ref) => updateRepo(repo.id, { worktreeBaseRef: ref })}
|
||||
onUsePrimary={() => updateRepo(repo.id, { worktreeBaseRef: undefined })}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,5 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
deriveNeededRepoIds,
|
||||
deriveNeededSectionIds,
|
||||
getRuntimeTargetIdentity
|
||||
} from './settings-load-performance'
|
||||
import { deriveNeededSectionIds } from './settings-load-performance'
|
||||
|
||||
describe('Settings load-performance helpers', () => {
|
||||
it('keeps only eager and active sections mounted for empty search on first paint', () => {
|
||||
|
|
@ -66,18 +62,4 @@ describe('Settings load-performance helpers', () => {
|
|||
|
||||
expect(needed.has('repo-a')).toBe(true)
|
||||
})
|
||||
|
||||
it('scopes repo hook checks to needed repo sections only', () => {
|
||||
const neededRepoIds = deriveNeededRepoIds(
|
||||
[{ id: 'a' }, { id: 'b' }, { id: 'c' }],
|
||||
new Set(['general', 'repo-b'])
|
||||
)
|
||||
|
||||
expect(neededRepoIds).toEqual(['b'])
|
||||
})
|
||||
|
||||
it('normalizes runtime target identity for cache invalidation keys', () => {
|
||||
expect(getRuntimeTargetIdentity({ activeRuntimeEnvironmentId: null })).toBe('local')
|
||||
expect(getRuntimeTargetIdentity({ activeRuntimeEnvironmentId: ' env-1 ' })).toBe('env-1')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import {
|
|||
type MutableRefObject
|
||||
} from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import type { GlobalSettings, OrcaHooks } from '../../../../shared/types'
|
||||
import type { GlobalSettings, OrcaHooks, ProjectHostSetup, Repo } from '../../../../shared/types'
|
||||
import type { SpeechModelState } from '../../../../shared/speech-types'
|
||||
import type {
|
||||
SourceControlAiSettings,
|
||||
|
|
@ -29,7 +29,11 @@ import {
|
|||
mergeFontSuggestions
|
||||
} from './SettingsConstants'
|
||||
import { DEFAULT_APP_FONT_FAMILY, getDefaultVoiceSettings } from '../../../../shared/constants'
|
||||
import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host'
|
||||
import {
|
||||
getRepoExecutionHostId,
|
||||
LOCAL_EXECUTION_HOST_ID,
|
||||
parseExecutionHostId
|
||||
} from '../../../../shared/execution-host'
|
||||
import { GeneralPane } from './GeneralPane'
|
||||
import { BrowserPane } from './BrowserPane'
|
||||
import { AppearancePane } from './AppearancePane'
|
||||
|
|
@ -96,14 +100,18 @@ import {
|
|||
useInstalledAgentSkill
|
||||
} from '@/hooks/useInstalledAgentSkills'
|
||||
import { useActiveProjectSkillRuntime } from '@/hooks/useActiveProjectSkillRuntime'
|
||||
import {
|
||||
deriveNeededRepoIds,
|
||||
deriveNeededSectionIds,
|
||||
getInitialMountedSectionIds,
|
||||
getRuntimeTargetIdentity
|
||||
} from './settings-load-performance'
|
||||
import { deriveNeededSectionIds, getInitialMountedSectionIds } from './settings-load-performance'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { getProjectHostSetupProjectionFromState } from '../../store/selectors'
|
||||
import { getRepoHostIdentity } from '../../store/slices/repo-host-identity'
|
||||
import {
|
||||
buildRepoIdToHostSelection,
|
||||
buildRepoIdToRepresentative,
|
||||
buildSettingsProjectList,
|
||||
getSettingsProjectHostRepo,
|
||||
removeSettingsProjectFromAllHosts,
|
||||
resolveSettingsTargetRepoId
|
||||
} from './settings-project-list'
|
||||
|
||||
const DevToolsPane = import.meta.env.DEV
|
||||
? lazy(() => import('./DevToolsPane').then((module) => ({ default: module.DevToolsPane })))
|
||||
|
|
@ -157,9 +165,16 @@ const SETTINGS_NAV_GROUP_BY_ID = new Map<string, SettingsNavGroupDefinition>(
|
|||
const SHORTCUTS_ESCAPE_CONFIRM_TOAST_ID = 'shortcuts-escape-confirm'
|
||||
const SHORTCUTS_ESCAPE_CONFIRM_WINDOW_MS = 2200
|
||||
|
||||
function getSettingsSectionId(pane: SettingsNavTarget, repoId: string | null): string {
|
||||
function getSettingsSectionId(
|
||||
pane: SettingsNavTarget,
|
||||
repoId: string | null,
|
||||
repoIdToRepresentative: Map<string, string>
|
||||
): string {
|
||||
if (pane === 'repo' && repoId) {
|
||||
return `repo-${repoId}`
|
||||
// Why: a `{pane:'repo', repoId}` target can name any host's repo row, but
|
||||
// Settings now renders one collapsed pane per project — resolve to that
|
||||
// project's representative section so the deep link lands.
|
||||
return `repo-${repoIdToRepresentative.get(repoId) ?? repoId}`
|
||||
}
|
||||
return pane
|
||||
}
|
||||
|
|
@ -283,12 +298,36 @@ function Settings(): React.JSX.Element {
|
|||
const removeProject = useAppStore((s) => s.removeProject)
|
||||
const settingsNavigationTarget = useAppStore((s) => s.settingsNavigationTarget)
|
||||
const clearSettingsTarget = useAppStore((s) => s.clearSettingsTarget)
|
||||
const settingsProjectHostSelection = useAppStore((s) => s.settingsProjectHostSelection)
|
||||
const setSettingsProjectHostSelection = useAppStore((s) => s.setSettingsProjectHostSelection)
|
||||
const settingsSearchInputQuery = useAppStore((s) => s.settingsSearchInputQuery)
|
||||
const settingsSearchQuery = useAppStore((s) => s.settingsSearchQuery)
|
||||
const setSettingsSearchQuery = useAppStore((s) => s.setSettingsSearchQuery)
|
||||
const modelStates = useAppStore((s) => s.modelStates)
|
||||
const refreshModelStates = useAppStore((s) => s.refreshModelStates)
|
||||
|
||||
// Why: collapse repo rows into one entry per project (derived from repos so it
|
||||
// matches the nav metadata exactly) — the source of truth for the pane list.
|
||||
const settingsProjectList = useMemo(() => buildSettingsProjectList(repos), [repos])
|
||||
const repoIdToRepresentative = useMemo(
|
||||
() => buildRepoIdToRepresentative(settingsProjectList),
|
||||
[settingsProjectList]
|
||||
)
|
||||
// Why: lets a deep-link's repoId select the owning project's host so
|
||||
// host-specific subsection anchors exist under the now-selected host.
|
||||
const repoIdToHostSelection = useMemo(
|
||||
() => buildRepoIdToHostSelection(settingsProjectList),
|
||||
[settingsProjectList]
|
||||
)
|
||||
// Why: the pane-level "Remove Project" removes the whole project (every host
|
||||
// setup), not just the selected host — the per-host remove lives inside
|
||||
// "Available Hosts".
|
||||
const removeProjectAllHosts = useCallback(
|
||||
(setups: readonly ProjectHostSetup[]): Promise<void> =>
|
||||
removeSettingsProjectFromAllHosts(setups, removeProject),
|
||||
[removeProject]
|
||||
)
|
||||
|
||||
const [repoHooksMap, setRepoHooksMap] = useState<
|
||||
Record<string, { hasHooks: boolean; hooks: OrcaHooks | null; mayNeedUpdate: boolean }>
|
||||
>({})
|
||||
|
|
@ -348,7 +387,6 @@ function Settings(): React.JSX.Element {
|
|||
const pendingScrollTargetRef = useRef<string | null>(null)
|
||||
const pendingSubsectionScrollFrameRef = useRef<number | null>(null)
|
||||
const repoHooksRequestSeqRef = useRef(0)
|
||||
const repoHooksRuntimeIdentityRef = useRef<string>('local')
|
||||
const shortcutsEscapeConfirmUntilRef = useRef(0)
|
||||
const sourceControlAiWriteQueueRef = useRef<Promise<void>>(Promise.resolve())
|
||||
|
||||
|
|
@ -499,8 +537,6 @@ function Settings(): React.JSX.Element {
|
|||
}
|
||||
}, [refreshModelStates, showDesktopOnlySettings])
|
||||
|
||||
const runtimeTargetIdentity = getRuntimeTargetIdentity(settings)
|
||||
|
||||
useEffect(() => {
|
||||
const hasVisibleOverlay = (): boolean =>
|
||||
Array.from(
|
||||
|
|
@ -615,8 +651,22 @@ function Settings(): React.JSX.Element {
|
|||
|
||||
const paneSectionId = getSettingsSectionId(
|
||||
settingsNavigationTarget.pane as SettingsNavTarget,
|
||||
settingsNavigationTarget.repoId
|
||||
settingsNavigationTarget.repoId,
|
||||
repoIdToRepresentative
|
||||
)
|
||||
// Why: couple the deep link to the in-pane host switcher before scrolling —
|
||||
// select the target repo's host so its host-specific subsection anchor
|
||||
// (e.g. `repo-<remoteId>-source-control-ai`) renders and the scroll lands.
|
||||
const targetRepoId = resolveSettingsTargetRepoId(
|
||||
settingsNavigationTarget,
|
||||
repoIdToHostSelection.keys()
|
||||
)
|
||||
if (targetRepoId) {
|
||||
const hostSelection = repoIdToHostSelection.get(targetRepoId)
|
||||
if (hostSelection) {
|
||||
setSettingsProjectHostSelection(hostSelection.projectId, hostSelection.hostId)
|
||||
}
|
||||
}
|
||||
pendingNavSectionRef.current = paneSectionId
|
||||
pendingScrollTargetRef.current = settingsNavigationTarget.sectionId ?? paneSectionId
|
||||
// Why: Appearance nests status-bar controls under a collapsed accordion;
|
||||
|
|
@ -640,7 +690,14 @@ function Settings(): React.JSX.Element {
|
|||
// scroll effect runs even when the visible section set is otherwise stable.
|
||||
setPendingNavRequestTick((tick) => tick + 1)
|
||||
clearSettingsTarget()
|
||||
}, [clearSettingsTarget, settings, settingsNavigationTarget])
|
||||
}, [
|
||||
clearSettingsTarget,
|
||||
repoIdToHostSelection,
|
||||
repoIdToRepresentative,
|
||||
setSettingsProjectHostSelection,
|
||||
settings,
|
||||
settingsNavigationTarget
|
||||
])
|
||||
|
||||
// Why: only recompute scrollback mode when the row value actually changes,
|
||||
// not on every unrelated settings mutation.
|
||||
|
|
@ -805,71 +862,80 @@ function Settings(): React.JSX.Element {
|
|||
setMountedSectionIds(neededSectionIds)
|
||||
}
|
||||
|
||||
const neededRepoIds = useMemo(
|
||||
() => deriveNeededRepoIds(repos, neededSectionIds),
|
||||
[neededSectionIds, repos]
|
||||
)
|
||||
// Why: each mounted project pane renders its SELECTED host's repo, so hooks
|
||||
// must load for that repo id — not the representative id parsed from the
|
||||
// section string (they differ when a non-default host is selected).
|
||||
const neededRepos = useMemo(() => {
|
||||
const reposByHostIdentity = new Map<string, Repo>()
|
||||
for (const settingsProject of settingsProjectList) {
|
||||
if (!neededSectionIds.has(`repo-${settingsProject.representativeRepoId}`)) {
|
||||
continue
|
||||
}
|
||||
const repo = getSettingsProjectHostRepo(
|
||||
settingsProject,
|
||||
repos,
|
||||
settingsProjectHostSelection[settingsProject.projectId]
|
||||
)
|
||||
if (repo) {
|
||||
reposByHostIdentity.set(getRepoHostIdentity(repo), repo)
|
||||
}
|
||||
}
|
||||
return [...reposByHostIdentity.values()]
|
||||
}, [neededSectionIds, repos, settingsProjectHostSelection, settingsProjectList])
|
||||
|
||||
useEffect(() => {
|
||||
const repoIdSet = new Set(repos.map((repo) => repo.id))
|
||||
const repoHostIdentitySet = new Set(repos.map(getRepoHostIdentity))
|
||||
setRepoHooksMap((previous) => {
|
||||
const next = Object.fromEntries(
|
||||
Object.entries(previous).filter(([repoId]) => repoIdSet.has(repoId))
|
||||
Object.entries(previous).filter(([identity]) => repoHostIdentitySet.has(identity))
|
||||
) as Record<string, { hasHooks: boolean; hooks: OrcaHooks | null; mayNeedUpdate: boolean }>
|
||||
return Object.keys(next).length === Object.keys(previous).length ? previous : next
|
||||
})
|
||||
}, [repos])
|
||||
|
||||
useEffect(() => {
|
||||
if (repoHooksRuntimeIdentityRef.current !== runtimeTargetIdentity) {
|
||||
repoHooksRuntimeIdentityRef.current = runtimeTargetIdentity
|
||||
repoHooksRequestSeqRef.current += 1
|
||||
setRepoHooksMap({})
|
||||
}
|
||||
}, [runtimeTargetIdentity])
|
||||
|
||||
useEffect(() => {
|
||||
if (neededRepoIds.length === 0) {
|
||||
if (neededRepos.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
let stale = false
|
||||
const requestSeq = ++repoHooksRequestSeqRef.current
|
||||
const repoById = new Map(repos.map((repo) => [repo.id, repo] as const))
|
||||
const liveRepoHostIdentities = new Set(repos.map(getRepoHostIdentity))
|
||||
|
||||
void Promise.all(
|
||||
neededRepoIds.map(async (repoId) => {
|
||||
const repo = repoById.get(repoId)
|
||||
if (!repo) {
|
||||
return
|
||||
}
|
||||
neededRepos.map(async (repo) => {
|
||||
const repoHostIdentity = getRepoHostIdentity(repo)
|
||||
if (isFolderRepo(repo)) {
|
||||
setRepoHooksMap((previous) => {
|
||||
if (previous[repoId]) {
|
||||
if (previous[repoHostIdentity]) {
|
||||
return previous
|
||||
}
|
||||
return {
|
||||
...previous,
|
||||
[repoId]: { hasHooks: false, hooks: null, mayNeedUpdate: false }
|
||||
[repoHostIdentity]: { hasHooks: false, hooks: null, mayNeedUpdate: false }
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
try {
|
||||
const hostId = getRepoExecutionHostId(repo)
|
||||
const parsedHost = parseExecutionHostId(hostId)
|
||||
const result = await checkRuntimeHooks(
|
||||
runtimeTargetIdentity === 'local'
|
||||
? { activeRuntimeEnvironmentId: null }
|
||||
: { activeRuntimeEnvironmentId: runtimeTargetIdentity },
|
||||
repoId
|
||||
{
|
||||
activeRuntimeEnvironmentId:
|
||||
parsedHost?.kind === 'runtime' ? parsedHost.environmentId : null
|
||||
},
|
||||
repo.id,
|
||||
hostId
|
||||
)
|
||||
if (stale || requestSeq !== repoHooksRequestSeqRef.current) {
|
||||
return
|
||||
}
|
||||
setRepoHooksMap((previous) => {
|
||||
if (!repos.some((entry) => entry.id === repoId)) {
|
||||
if (!liveRepoHostIdentities.has(repoHostIdentity)) {
|
||||
return previous
|
||||
}
|
||||
return { ...previous, [repoId]: result }
|
||||
return { ...previous, [repoHostIdentity]: result }
|
||||
})
|
||||
} catch {
|
||||
// Keep last known value on transient failures.
|
||||
|
|
@ -877,15 +943,15 @@ function Settings(): React.JSX.Element {
|
|||
return
|
||||
}
|
||||
setRepoHooksMap((previous) => {
|
||||
if (!repos.some((entry) => entry.id === repoId)) {
|
||||
if (!liveRepoHostIdentities.has(repoHostIdentity)) {
|
||||
return previous
|
||||
}
|
||||
if (previous[repoId]) {
|
||||
if (previous[repoHostIdentity]) {
|
||||
return previous
|
||||
}
|
||||
return {
|
||||
...previous,
|
||||
[repoId]: { hasHooks: false, hooks: null, mayNeedUpdate: false }
|
||||
[repoHostIdentity]: { hasHooks: false, hooks: null, mayNeedUpdate: false }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -895,7 +961,7 @@ function Settings(): React.JSX.Element {
|
|||
return () => {
|
||||
stale = true
|
||||
}
|
||||
}, [neededRepoIds, repos, runtimeTargetIdentity])
|
||||
}, [neededRepos, repos])
|
||||
|
||||
useEffect(() => {
|
||||
const scrollTargetId = pendingScrollTargetRef.current
|
||||
|
|
@ -1646,32 +1712,47 @@ function Settings(): React.JSX.Element {
|
|||
) : null}
|
||||
</SettingsSection>
|
||||
|
||||
{repos.map((repo) => {
|
||||
const repoSectionId = `repo-${repo.id}`
|
||||
const repoHooksState = repoHooksMap[repo.id]
|
||||
const project = projectByRepoId.get(repo.id) ?? null
|
||||
{settingsProjectList.map((settingsProject) => {
|
||||
const repoSectionId = `repo-${settingsProject.representativeRepoId}`
|
||||
// Why: render the switcher-selected host's repo row (validated
|
||||
// against live setups) so identity edits and host-specific
|
||||
// settings follow the "Available Hosts" selection.
|
||||
const repo = getSettingsProjectHostRepo(
|
||||
settingsProject,
|
||||
repos,
|
||||
settingsProjectHostSelection[settingsProject.projectId]
|
||||
)
|
||||
if (!repo) {
|
||||
return null
|
||||
}
|
||||
const repoHostIdentity = getRepoHostIdentity(repo)
|
||||
const repoHooksState = repoHooksMap[repoHostIdentity]
|
||||
const project = projectByRepoId.get(repo.id) ?? settingsProject.project
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
key={repo.id}
|
||||
key={repoSectionId}
|
||||
id={repoSectionId}
|
||||
title={translate(
|
||||
'auto.components.settings.Settings.3bf149e873',
|
||||
'Project Settings > {{value0}}',
|
||||
{ value0: repo.displayName }
|
||||
{ value0: project.displayName }
|
||||
)}
|
||||
description={repo.path}
|
||||
searchEntries={getSectionSearchEntries(repoSectionId)}
|
||||
>
|
||||
{isSectionMounted(repoSectionId) ? (
|
||||
// Why: same-id hosts otherwise reuse drafts and effects
|
||||
// from the previously selected host inside this pane.
|
||||
<RepositoryPane
|
||||
key={repoHostIdentity}
|
||||
repo={repo}
|
||||
yamlHooks={repoHooksState?.hooks ?? null}
|
||||
hasHooksFile={repoHooksState?.hasHooks ?? false}
|
||||
hooksInspectionReady={Boolean(repoHooksState)}
|
||||
mayNeedUpdate={repoHooksState?.mayNeedUpdate ?? false}
|
||||
updateRepo={updateRepo}
|
||||
removeProject={removeProject}
|
||||
removeProject={() => void removeProjectAllHosts(settingsProject.setups)}
|
||||
project={project}
|
||||
isLocalWindowsProject={
|
||||
getRepoExecutionHostId(repo) === LOCAL_EXECUTION_HOST_ID &&
|
||||
|
|
|
|||
|
|
@ -11,8 +11,8 @@ import {
|
|||
getWorktreeSymlinkPathFilterState,
|
||||
type WorktreeSymlinkPathSuggestion
|
||||
} from './worktree-symlink-path-filter'
|
||||
import { useAppStore } from '@/store'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host'
|
||||
|
||||
type WorktreeSymlinksSectionProps = {
|
||||
repo: Repo
|
||||
|
|
@ -32,10 +32,11 @@ export function WorktreeSymlinksSection({
|
|||
}: WorktreeSymlinksSectionProps): React.JSX.Element {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
const activeRuntimeEnvironmentId = useAppStore((s) => s.settings?.activeRuntimeEnvironmentId)
|
||||
|
||||
const paths = repo.symlinkPaths ?? EMPTY_WORKTREE_SYMLINK_PATHS
|
||||
const useLocalDirectorySuggestions = !activeRuntimeEnvironmentId?.trim()
|
||||
// Why: the pane may show a non-focused runtime host; only inspect the local
|
||||
// filesystem when the switcher-selected repo is actually local.
|
||||
const useLocalDirectorySuggestions = getRepoExecutionHostId(repo) === LOCAL_EXECUTION_HOST_ID
|
||||
const directorySuggestionKey = `${repo.path}\n${repo.connectionId ?? ''}`
|
||||
const [directorySuggestions, setDirectorySuggestions] = useState<DirectorySuggestionState>(
|
||||
() => ({
|
||||
|
|
|
|||
|
|
@ -1,13 +1,5 @@
|
|||
import type { GlobalSettings } from '../../../../shared/types'
|
||||
|
||||
const EAGER_SECTION_IDS = new Set(['general'])
|
||||
|
||||
export function getRuntimeTargetIdentity(
|
||||
settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined
|
||||
): string {
|
||||
return settings?.activeRuntimeEnvironmentId?.trim() || 'local'
|
||||
}
|
||||
|
||||
export function deriveNeededSectionIds(args: {
|
||||
navSectionIds: string[]
|
||||
mountedSectionIds: Set<string>
|
||||
|
|
@ -37,13 +29,6 @@ export function deriveNeededSectionIds(args: {
|
|||
return next
|
||||
}
|
||||
|
||||
export function deriveNeededRepoIds(
|
||||
repos: readonly { id: string }[],
|
||||
neededSectionIds: Set<string>
|
||||
): string[] {
|
||||
return repos.map((repo) => repo.id).filter((repoId) => neededSectionIds.has(`repo-${repoId}`))
|
||||
}
|
||||
|
||||
export function getInitialMountedSectionIds(): Set<string> {
|
||||
return new Set(EAGER_SECTION_IDS)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,257 @@
|
|||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { ProjectHostSetup, Repo } from '../../../../shared/types'
|
||||
import {
|
||||
buildRepoIdToHostSelection,
|
||||
buildRepoIdToRepresentative,
|
||||
buildSettingsProjectList,
|
||||
getSettingsProjectHostRepo,
|
||||
getSettingsProjectRepresentativeRepoId,
|
||||
removeSettingsProjectFromAllHosts,
|
||||
resolveEffectiveProjectHost,
|
||||
resolveSettingsTargetRepoId
|
||||
} from './settings-project-list'
|
||||
|
||||
function makeRepo(overrides: Partial<Repo> & Pick<Repo, 'id'>): Repo {
|
||||
return {
|
||||
path: `/repos/${overrides.id}`,
|
||||
displayName: overrides.id,
|
||||
badgeColor: '#000',
|
||||
addedAt: 0,
|
||||
...overrides
|
||||
} satisfies Repo
|
||||
}
|
||||
|
||||
function makeSetup(
|
||||
overrides: Partial<ProjectHostSetup> & Pick<ProjectHostSetup, 'hostId'>
|
||||
): ProjectHostSetup {
|
||||
return {
|
||||
id: `${overrides.hostId}:${overrides.repoId ?? 'r'}`,
|
||||
projectId: 'p',
|
||||
repoId: overrides.repoId ?? 'r',
|
||||
path: '/repo',
|
||||
displayName: 'r',
|
||||
setupState: 'ready',
|
||||
setupMethod: 'legacy-repo',
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
...overrides
|
||||
} satisfies ProjectHostSetup
|
||||
}
|
||||
|
||||
const gitRemote = {
|
||||
canonicalKey: 'gitlab.com/acme/app',
|
||||
remoteName: 'origin',
|
||||
remoteUrl: 'git@gitlab.com:acme/app.git'
|
||||
}
|
||||
|
||||
describe('buildSettingsProjectList', () => {
|
||||
it('collapses a git same-remote pair on two hosts (different ids) into one project', () => {
|
||||
const repos: Repo[] = [
|
||||
makeRepo({ id: 'local-1', gitRemoteIdentity: gitRemote }),
|
||||
makeRepo({
|
||||
id: 'remote-9',
|
||||
gitRemoteIdentity: gitRemote,
|
||||
executionHostId: 'runtime:home-mac'
|
||||
})
|
||||
]
|
||||
|
||||
const projects = buildSettingsProjectList(repos)
|
||||
|
||||
expect(projects).toHaveLength(1)
|
||||
expect(projects[0].setups).toHaveLength(2)
|
||||
// Representative is the local host's repo.
|
||||
expect(projects[0].representativeRepoId).toBe('local-1')
|
||||
})
|
||||
|
||||
it('collapses a folder with the same id on local + runtime into one project', () => {
|
||||
const repos: Repo[] = [
|
||||
makeRepo({ id: 'folder-x', kind: 'folder' }),
|
||||
makeRepo({ id: 'folder-x', kind: 'folder', executionHostId: 'runtime:home-mac' })
|
||||
]
|
||||
|
||||
const projects = buildSettingsProjectList(repos)
|
||||
|
||||
expect(projects).toHaveLength(1)
|
||||
expect(projects[0].setups).toHaveLength(2)
|
||||
expect(projects[0].representativeRepoId).toBe('folder-x')
|
||||
})
|
||||
|
||||
it('keeps the representative stable when an unrelated host is removed', () => {
|
||||
const withRuntime: Repo[] = [
|
||||
makeRepo({ id: 'local-1', gitRemoteIdentity: gitRemote }),
|
||||
makeRepo({
|
||||
id: 'remote-9',
|
||||
gitRemoteIdentity: gitRemote,
|
||||
executionHostId: 'runtime:home-mac'
|
||||
})
|
||||
]
|
||||
const localOnly: Repo[] = [makeRepo({ id: 'local-1', gitRemoteIdentity: gitRemote })]
|
||||
|
||||
expect(buildSettingsProjectList(withRuntime)[0].representativeRepoId).toBe(
|
||||
buildSettingsProjectList(localOnly)[0].representativeRepoId
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getSettingsProjectRepresentativeRepoId', () => {
|
||||
it('prefers the local host setup', () => {
|
||||
const setups = [
|
||||
makeSetup({ hostId: 'runtime:home-mac', repoId: 'aaa' }),
|
||||
makeSetup({ hostId: 'local', repoId: 'zzz' })
|
||||
]
|
||||
expect(getSettingsProjectRepresentativeRepoId(setups)).toBe('zzz')
|
||||
})
|
||||
|
||||
it('falls back to the lowest repoId when there is no local setup', () => {
|
||||
const setups = [
|
||||
makeSetup({ hostId: 'runtime:home-mac', repoId: 'zzz' }),
|
||||
makeSetup({ hostId: 'ssh:box', repoId: 'aaa' })
|
||||
]
|
||||
expect(getSettingsProjectRepresentativeRepoId(setups)).toBe('aaa')
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveEffectiveProjectHost', () => {
|
||||
const setups = [
|
||||
makeSetup({ hostId: 'local', repoId: 'local-1' }),
|
||||
makeSetup({ hostId: 'runtime:home-mac', repoId: 'remote-9' })
|
||||
]
|
||||
|
||||
it('keeps a valid stored selection', () => {
|
||||
expect(resolveEffectiveProjectHost(setups, 'runtime:home-mac')).toBe('runtime:home-mac')
|
||||
})
|
||||
|
||||
it('falls back to local when the stored host no longer exists', () => {
|
||||
expect(resolveEffectiveProjectHost(setups, 'runtime:gone')).toBe('local')
|
||||
})
|
||||
|
||||
it('falls back to the first ready setup when there is no local host', () => {
|
||||
const remoteSetups = [
|
||||
makeSetup({ hostId: 'ssh:box', repoId: 'a', setupState: 'not-set-up' }),
|
||||
makeSetup({ hostId: 'runtime:home-mac', repoId: 'b', setupState: 'ready' })
|
||||
]
|
||||
expect(resolveEffectiveProjectHost(remoteSetups, 'runtime:gone')).toBe('runtime:home-mac')
|
||||
})
|
||||
|
||||
it('returns undefined when there are no setups', () => {
|
||||
expect(resolveEffectiveProjectHost([], 'local')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('deep-link resolution', () => {
|
||||
const repos: Repo[] = [
|
||||
makeRepo({ id: 'local-1', gitRemoteIdentity: gitRemote }),
|
||||
makeRepo({ id: 'remote-9', gitRemoteIdentity: gitRemote, executionHostId: 'runtime:home-mac' })
|
||||
]
|
||||
const projects = buildSettingsProjectList(repos)
|
||||
|
||||
it('maps every host repoId to the representative section (getSettingsSectionId resolver)', () => {
|
||||
const map = buildRepoIdToRepresentative(projects)
|
||||
expect(map.get('remote-9')).toBe('local-1')
|
||||
expect(map.get('local-1')).toBe('local-1')
|
||||
})
|
||||
|
||||
it('maps a repoId to its owning project + host for selection', () => {
|
||||
const map = buildRepoIdToHostSelection(projects)
|
||||
expect(map.get('remote-9')).toEqual({
|
||||
projectId: projects[0].projectId,
|
||||
hostId: 'runtime:home-mac'
|
||||
})
|
||||
})
|
||||
|
||||
it('parses a repoId from a host-specific subsection sectionId', () => {
|
||||
const repoIds = [...buildRepoIdToHostSelection(projects).keys()]
|
||||
expect(
|
||||
resolveSettingsTargetRepoId(
|
||||
{ repoId: null, sectionId: 'repo-remote-9-source-control-ai' },
|
||||
repoIds
|
||||
)
|
||||
).toBe('remote-9')
|
||||
})
|
||||
|
||||
it('prefers an explicit target repoId over the sectionId', () => {
|
||||
expect(
|
||||
resolveSettingsTargetRepoId({ repoId: 'local-1', sectionId: 'repo-remote-9-icon' }, [
|
||||
'local-1',
|
||||
'remote-9'
|
||||
])
|
||||
).toBe('local-1')
|
||||
})
|
||||
|
||||
it('disambiguates repo ids where one is a prefix of another (longest match wins)', () => {
|
||||
expect(
|
||||
resolveSettingsTargetRepoId({ repoId: null, sectionId: 'repo-app-2-icon' }, ['app', 'app-2'])
|
||||
).toBe('app-2')
|
||||
})
|
||||
|
||||
it('resolves the remote host repo row when a remote host is selected', () => {
|
||||
const hostSelection = buildRepoIdToHostSelection(projects).get('remote-9')
|
||||
expect(getSettingsProjectHostRepo(projects[0], repos, hostSelection?.hostId)?.id).toBe(
|
||||
'remote-9'
|
||||
)
|
||||
})
|
||||
|
||||
it('defaults to the local host repo row when no host is selected', () => {
|
||||
expect(getSettingsProjectHostRepo(projects[0], repos, undefined)?.id).toBe('local-1')
|
||||
})
|
||||
|
||||
it('distinguishes same-id repo rows by execution host', () => {
|
||||
const sameIdRepos = [
|
||||
makeRepo({ id: 'same-repo', gitRemoteIdentity: gitRemote }),
|
||||
makeRepo({
|
||||
id: 'same-repo',
|
||||
gitRemoteIdentity: gitRemote,
|
||||
executionHostId: 'runtime:home-mac',
|
||||
path: '/remote/repo'
|
||||
})
|
||||
]
|
||||
const sameIdProjects = buildSettingsProjectList(sameIdRepos)
|
||||
|
||||
expect(
|
||||
getSettingsProjectHostRepo(sameIdProjects[0], sameIdRepos, 'runtime:home-mac')?.path
|
||||
).toBe('/remote/repo')
|
||||
})
|
||||
})
|
||||
|
||||
describe('removeSettingsProjectFromAllHosts', () => {
|
||||
it('removes every host setup with its own hostId and skips setups without a repo row', async () => {
|
||||
const removeProject = vi.fn().mockResolvedValue(undefined)
|
||||
const setups = [
|
||||
makeSetup({ hostId: 'local', repoId: 'local-1' }),
|
||||
makeSetup({ hostId: 'ssh:box', repoId: ' ' }),
|
||||
makeSetup({ hostId: 'runtime:home-mac', repoId: 'remote-9' })
|
||||
]
|
||||
|
||||
await removeSettingsProjectFromAllHosts(setups, removeProject)
|
||||
|
||||
expect(removeProject.mock.calls).toEqual([
|
||||
['local-1', { hostId: 'local' }],
|
||||
['remote-9', { hostId: 'runtime:home-mac' }]
|
||||
])
|
||||
})
|
||||
|
||||
it('awaits each host removal before starting the next', async () => {
|
||||
let resolveFirst: (() => void) | undefined
|
||||
const removeProject = vi
|
||||
.fn()
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
resolveFirst = resolve
|
||||
})
|
||||
)
|
||||
.mockResolvedValue(undefined)
|
||||
const setups = [
|
||||
makeSetup({ hostId: 'local', repoId: 'local-1' }),
|
||||
makeSetup({ hostId: 'runtime:home-mac', repoId: 'remote-9' })
|
||||
]
|
||||
|
||||
const pending = removeSettingsProjectFromAllHosts(setups, removeProject)
|
||||
await Promise.resolve()
|
||||
expect(removeProject).toHaveBeenCalledTimes(1)
|
||||
|
||||
resolveFirst?.()
|
||||
await pending
|
||||
expect(removeProject).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,194 @@
|
|||
import type { Project, ProjectHostSetup, Repo } from '../../../../shared/types'
|
||||
import {
|
||||
getRepoExecutionHostId,
|
||||
LOCAL_EXECUTION_HOST_ID,
|
||||
type ExecutionHostId
|
||||
} from '../../../../shared/execution-host'
|
||||
import { projectHostSetupProjectionFromRepos } from '../../../../shared/project-host-setup-projection'
|
||||
|
||||
export type SettingsProject = {
|
||||
projectId: string
|
||||
project: Project
|
||||
setups: ProjectHostSetup[]
|
||||
representativeRepoId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Which repo row identifies a project's single Settings nav row + pane. Pure
|
||||
* over the project's setups so nav and panes derive the same id. Prefers the
|
||||
* `local` host (the user's own machine) and otherwise the lowest repoId, so the
|
||||
* id is stable unless that exact repo row is removed.
|
||||
*/
|
||||
export function getSettingsProjectRepresentativeRepoId(
|
||||
setups: readonly ProjectHostSetup[]
|
||||
): string {
|
||||
const localSetup = setups.find(
|
||||
(setup) => setup.hostId === LOCAL_EXECUTION_HOST_ID && setup.repoId.trim().length > 0
|
||||
)
|
||||
if (localSetup) {
|
||||
return localSetup.repoId
|
||||
}
|
||||
let lowest = ''
|
||||
for (const setup of setups) {
|
||||
const repoId = setup.repoId.trim()
|
||||
if (repoId.length > 0 && (lowest === '' || repoId < lowest)) {
|
||||
lowest = repoId
|
||||
}
|
||||
}
|
||||
return lowest
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapses repo rows into one entry per project so Settings renders per
|
||||
* project, matching the rest of the app. Derived from repos alone (not the
|
||||
* persisted projects/setups) so the nav and pane lists agree exactly.
|
||||
*/
|
||||
export function buildSettingsProjectList(repos: readonly Repo[]): SettingsProject[] {
|
||||
const projection = projectHostSetupProjectionFromRepos(repos)
|
||||
const setupsByProjectId = new Map<string, ProjectHostSetup[]>()
|
||||
for (const setup of projection.setups) {
|
||||
const projectSetups = setupsByProjectId.get(setup.projectId)
|
||||
if (projectSetups) {
|
||||
projectSetups.push(setup)
|
||||
} else {
|
||||
setupsByProjectId.set(setup.projectId, [setup])
|
||||
}
|
||||
}
|
||||
return projection.projects.map((project) => {
|
||||
// Why: Settings metadata is rebuilt as repos refresh across hosts; index
|
||||
// setups once so many projects do not turn each refresh into an O(n²) scan.
|
||||
const setups = setupsByProjectId.get(project.id) ?? []
|
||||
return {
|
||||
projectId: project.id,
|
||||
project,
|
||||
setups,
|
||||
representativeRepoId: getSettingsProjectRepresentativeRepoId(setups)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* The host whose settings the project pane should show. Validates the stored
|
||||
* selection against the live setups so a disconnected/removed host never leaves
|
||||
* the pane rendering off a dangling hostId: falls back to local, then the first
|
||||
* ready setup, then the first setup.
|
||||
*/
|
||||
export function resolveEffectiveProjectHost(
|
||||
setups: readonly ProjectHostSetup[],
|
||||
selectedHostId: ExecutionHostId | undefined
|
||||
): ExecutionHostId | undefined {
|
||||
if (setups.length === 0) {
|
||||
return undefined
|
||||
}
|
||||
if (selectedHostId && setups.some((setup) => setup.hostId === selectedHostId)) {
|
||||
return selectedHostId
|
||||
}
|
||||
const localSetup = setups.find((setup) => setup.hostId === LOCAL_EXECUTION_HOST_ID)
|
||||
if (localSetup) {
|
||||
return localSetup.hostId
|
||||
}
|
||||
const readySetup = setups.find((setup) => setup.setupState === 'ready')
|
||||
return (readySetup ?? setups[0]).hostId
|
||||
}
|
||||
|
||||
/** Maps every host's repoId to its project's representative repoId, so a
|
||||
* `{pane:'repo', repoId}` deep link resolves to the collapsed pane. */
|
||||
export function buildRepoIdToRepresentative(
|
||||
projects: readonly SettingsProject[]
|
||||
): Map<string, string> {
|
||||
const map = new Map<string, string>()
|
||||
for (const settingsProject of projects) {
|
||||
for (const setup of settingsProject.setups) {
|
||||
if (setup.repoId.trim().length > 0) {
|
||||
map.set(setup.repoId, settingsProject.representativeRepoId)
|
||||
}
|
||||
}
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
/** Maps each host's repoId to its owning project + host, so a deep link can
|
||||
* select that host in the pane's "Available Hosts" switcher. */
|
||||
export function buildRepoIdToHostSelection(
|
||||
projects: readonly SettingsProject[]
|
||||
): Map<string, { projectId: string; hostId: ExecutionHostId }> {
|
||||
const map = new Map<string, { projectId: string; hostId: ExecutionHostId }>()
|
||||
for (const settingsProject of projects) {
|
||||
for (const setup of settingsProject.setups) {
|
||||
if (setup.repoId.trim().length > 0 && !map.has(setup.repoId)) {
|
||||
map.set(setup.repoId, { projectId: settingsProject.projectId, hostId: setup.hostId })
|
||||
}
|
||||
}
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
/**
|
||||
* The repo row a Settings deep link points at, from either an explicit repoId
|
||||
* or a `repo-<id>-<subsection>` sectionId. repo ids can contain hyphens, so the
|
||||
* sectionId is matched against known ids with the longest match winning.
|
||||
*/
|
||||
export function resolveSettingsTargetRepoId(
|
||||
target: { repoId: string | null; sectionId?: string },
|
||||
repoIds: Iterable<string>
|
||||
): string | null {
|
||||
if (target.repoId) {
|
||||
return target.repoId
|
||||
}
|
||||
const sectionId = target.sectionId
|
||||
if (!sectionId || !sectionId.startsWith('repo-')) {
|
||||
return null
|
||||
}
|
||||
let best: string | null = null
|
||||
for (const repoId of repoIds) {
|
||||
if (sectionId === `repo-${repoId}` || sectionId.startsWith(`repo-${repoId}-`)) {
|
||||
if (best === null || repoId.length > best.length) {
|
||||
best = repoId
|
||||
}
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a project's setup on every host it exists on. Sequential so each
|
||||
* host's teardown + projection recompute don't interleave; setups without a
|
||||
* repo row (planned/not-set-up hosts) have nothing to remove.
|
||||
*/
|
||||
export async function removeSettingsProjectFromAllHosts(
|
||||
setups: readonly ProjectHostSetup[],
|
||||
removeProject: (repoId: string, options: { hostId: ExecutionHostId }) => Promise<void>
|
||||
): Promise<void> {
|
||||
for (const setup of setups) {
|
||||
if (setup.repoId.trim().length > 0) {
|
||||
await removeProject(setup.repoId, { hostId: setup.hostId })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The repo row the project pane should render for the given host selection.
|
||||
* Shared by the pane and the hooks-loading effect so they always agree on which
|
||||
* host's repo (id + host) is mounted — critical in the same-id/self-pair case.
|
||||
*/
|
||||
export function getSettingsProjectHostRepo(
|
||||
settingsProject: SettingsProject,
|
||||
repos: readonly Repo[],
|
||||
selectedHostId: ExecutionHostId | undefined
|
||||
): Repo | undefined {
|
||||
const effectiveHostId = resolveEffectiveProjectHost(settingsProject.setups, selectedHostId)
|
||||
if (!effectiveHostId) {
|
||||
return undefined
|
||||
}
|
||||
const effectiveSetup =
|
||||
settingsProject.setups.find((setup) => setup.hostId === effectiveHostId) ??
|
||||
settingsProject.setups[0]
|
||||
return (
|
||||
repos.find(
|
||||
(repo) =>
|
||||
repo.id === effectiveSetup.repoId && getRepoExecutionHostId(repo) === effectiveHostId
|
||||
) ??
|
||||
repos.find((repo) => repo.id === effectiveSetup.repoId) ??
|
||||
repos.find((repo) => repo.id === settingsProject.representativeRepoId)
|
||||
)
|
||||
}
|
||||
|
|
@ -181,6 +181,42 @@ describe('settings navigation metadata', () => {
|
|||
expect(ids({ isDev: true, isWebClient: true })).not.toContain('dev')
|
||||
})
|
||||
|
||||
it('renders one repo nav section per project even across execution hosts', () => {
|
||||
const gitRemote = {
|
||||
canonicalKey: 'gitlab.com/acme/app',
|
||||
remoteName: 'origin',
|
||||
remoteUrl: 'git@gitlab.com:acme/app.git'
|
||||
}
|
||||
const sections = buildSettingsNavigationMetadata({
|
||||
isMac: false,
|
||||
isWindows: false,
|
||||
isWebClient: false,
|
||||
repos: [
|
||||
{
|
||||
id: 'local-1',
|
||||
path: '/a',
|
||||
displayName: 'App',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0,
|
||||
gitRemoteIdentity: gitRemote
|
||||
},
|
||||
{
|
||||
id: 'remote-9',
|
||||
path: '/b',
|
||||
displayName: 'App',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0,
|
||||
gitRemoteIdentity: gitRemote,
|
||||
executionHostId: 'runtime:home-mac'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const repoSections = sections.filter((section) => section.id.startsWith('repo-'))
|
||||
expect(repoSections).toHaveLength(1)
|
||||
expect(repoSections[0].id).toBe('repo-local-1')
|
||||
})
|
||||
|
||||
it('keeps macOS permissions mac-only', () => {
|
||||
expect(ids({ isMac: false })).not.toContain('developer-permissions')
|
||||
expect(ids({ isMac: true })).toContain('developer-permissions')
|
||||
|
|
|
|||
|
|
@ -71,6 +71,7 @@ import { getShortcutsPaneSearchEntries } from '@/components/settings/shortcuts-s
|
|||
import { getStatsPaneSearchEntries } from '@/components/stats/stats-search'
|
||||
import { getExperimentalPaneSearchEntries } from '@/components/settings/experimental-search'
|
||||
import { getRepositoryPaneSearchEntries } from '@/components/settings/repository-search'
|
||||
import { buildSettingsProjectList } from '@/components/settings/settings-project-list'
|
||||
import { isWebClientLocation } from '@/lib/web-client-location'
|
||||
import {
|
||||
getWindowsTerminalCapabilityOwnerKey,
|
||||
|
|
@ -130,6 +131,12 @@ export function buildSettingsNavigationMetadata({
|
|||
const runtimeEnvironmentsSearchEntry = isWebClient
|
||||
? getWebRuntimeEnvironmentsSearchEntry()
|
||||
: getRuntimeEnvironmentsSearchEntry()
|
||||
const reposById = new Map<string, Repo>()
|
||||
for (const repo of repos) {
|
||||
if (!reposById.has(repo.id)) {
|
||||
reposById.set(repo.id, repo)
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
// Why: this array's order must mirror SETTINGS_NAV_GROUPS so the Settings
|
||||
|
|
@ -537,16 +544,30 @@ export function buildSettingsNavigationMetadata({
|
|||
searchEntries: getExperimentalPaneSearchEntries(),
|
||||
group: 'experimental'
|
||||
},
|
||||
...repos.map((repo) => ({
|
||||
id: `repo-${repo.id}`,
|
||||
title: repo.displayName,
|
||||
description: `${getRepoKindLabel(repo)} • ${repo.path}`,
|
||||
icon: SlidersHorizontal,
|
||||
searchEntries: getRepositoryPaneSearchEntries(repo, {
|
||||
windowsRuntimeSupported: isWindowsTerminalHost
|
||||
}),
|
||||
group: 'repositories'
|
||||
}))
|
||||
// Why: one nav row per project, not per repo row — a project set up on
|
||||
// multiple hosts (local + a Remote Orca Server, or two clones) collapses to
|
||||
// a single entry. Derived from repos alone so this list matches the panes.
|
||||
...buildSettingsProjectList(repos).map(({ project, representativeRepoId, setups }) => {
|
||||
const representativeRepo = reposById.get(representativeRepoId) ?? repos[0]
|
||||
const hostSummary =
|
||||
setups.length > 1
|
||||
? translate(
|
||||
'auto.hooks.useSettingsNavigationMetadata.projectHostsSummary',
|
||||
'{{value0}} hosts',
|
||||
{ value0: setups.length }
|
||||
)
|
||||
: (setups[0]?.path ?? representativeRepo.path)
|
||||
return {
|
||||
id: `repo-${representativeRepoId}`,
|
||||
title: project.displayName,
|
||||
description: `${getRepoKindLabel(project)} • ${hostSummary}`,
|
||||
icon: SlidersHorizontal,
|
||||
searchEntries: getRepositoryPaneSearchEntries(representativeRepo, {
|
||||
windowsRuntimeSupported: isWindowsTerminalHost
|
||||
}),
|
||||
group: 'repositories'
|
||||
}
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -699,7 +699,8 @@
|
|||
"devSearchKeywordToast": "toast",
|
||||
"devSearchKeywordSonner": "sonner",
|
||||
"devSearchKeywordError": "error",
|
||||
"devSearchKeywordNotification": "notification"
|
||||
"devSearchKeywordNotification": "notification",
|
||||
"projectHostsSummary": "{{value0}} hosts"
|
||||
},
|
||||
"useAppMenuPaste": {
|
||||
"pasteTooLarge": "Paste is too large."
|
||||
|
|
@ -6127,7 +6128,7 @@
|
|||
"26fef02bf3": "Project Icon",
|
||||
"c7ef4415de": "Display Name",
|
||||
"b0a0c14a1c": "Project-specific display details for the sidebar and tabs.",
|
||||
"170624bdfb": "Remove this project from Orca.",
|
||||
"removeProjectAllHosts": "Remove this project from Orca on all configured hosts.",
|
||||
"0909e5d650": "Remove Project",
|
||||
"ee5a290616": "Opened as folder. Git features are unavailable for this workspace.",
|
||||
"323debba71": "Type:",
|
||||
|
|
|
|||
|
|
@ -699,7 +699,8 @@
|
|||
"devSearchKeywordToast": "toast",
|
||||
"devSearchKeywordSonner": "sonner",
|
||||
"devSearchKeywordError": "error",
|
||||
"devSearchKeywordNotification": "notification"
|
||||
"devSearchKeywordNotification": "notification",
|
||||
"projectHostsSummary": "{{value0}} hosts"
|
||||
},
|
||||
"useAppMenuPaste": {
|
||||
"pasteTooLarge": "El contenido pegado es demasiado grande."
|
||||
|
|
@ -6090,7 +6091,7 @@
|
|||
"26fef02bf3": "Icono de proyecto",
|
||||
"c7ef4415de": "Nombre para mostrar",
|
||||
"b0a0c14a1c": "Detalles de visualización específicos del proyecto para la barra lateral y las pestañas.",
|
||||
"170624bdfb": "Eliminar este proyecto de Orca.",
|
||||
"removeProjectAllHosts": "Eliminar este proyecto de Orca en todos los hosts configurados.",
|
||||
"0909e5d650": "Eliminar proyecto",
|
||||
"ee5a290616": "Abierto como carpeta. Las funciones de Git no están disponibles para este espacio de trabajo.",
|
||||
"323debba71": "Tipo:",
|
||||
|
|
|
|||
|
|
@ -699,7 +699,8 @@
|
|||
"devSearchKeywordToast": "toast",
|
||||
"devSearchKeywordSonner": "sonner",
|
||||
"devSearchKeywordError": "error",
|
||||
"devSearchKeywordNotification": "notification"
|
||||
"devSearchKeywordNotification": "notification",
|
||||
"projectHostsSummary": "{{value0}} 台のホスト"
|
||||
},
|
||||
"useAppMenuPaste": {
|
||||
"pasteTooLarge": "貼り付け内容が大きすぎます。"
|
||||
|
|
@ -6112,7 +6113,7 @@
|
|||
"26fef02bf3": "プロジェクトアイコン",
|
||||
"c7ef4415de": "表示名",
|
||||
"b0a0c14a1c": "サイドバーとタブのプロジェクト固有の表示詳細。",
|
||||
"170624bdfb": "このプロジェクトを Orca から削除します。",
|
||||
"removeProjectAllHosts": "このプロジェクトを、設定済みのすべてのホストの Orca から削除します。",
|
||||
"0909e5d650": "プロジェクトの削除",
|
||||
"ee5a290616": "フォルダーとして開きます。このワークスペースでは Git 機能を使用できません。",
|
||||
"323debba71": "タイプ:",
|
||||
|
|
|
|||
|
|
@ -699,7 +699,8 @@
|
|||
"devSearchKeywordToast": "toast",
|
||||
"devSearchKeywordSonner": "sonner",
|
||||
"devSearchKeywordError": "error",
|
||||
"devSearchKeywordNotification": "notification"
|
||||
"devSearchKeywordNotification": "notification",
|
||||
"projectHostsSummary": "호스트 {{value0}}개"
|
||||
},
|
||||
"useAppMenuPaste": {
|
||||
"pasteTooLarge": "붙여넣기 내용이 너무 큽니다."
|
||||
|
|
@ -6075,7 +6076,7 @@
|
|||
"26fef02bf3": "프로젝트 아이콘",
|
||||
"c7ef4415de": "표시 이름",
|
||||
"b0a0c14a1c": "사이드바 및 탭에 대한 프로젝트별 표시 세부 정보입니다.",
|
||||
"170624bdfb": "Orca에서 이 프로젝트를 제거하세요.",
|
||||
"removeProjectAllHosts": "구성된 모든 호스트의 Orca에서 이 프로젝트를 제거합니다.",
|
||||
"0909e5d650": "프로젝트 제거",
|
||||
"ee5a290616": "폴더로 열렸습니다. 이 워크스페이스에서는 Git 기능을 사용할 수 없습니다.",
|
||||
"323debba71": "유형:",
|
||||
|
|
|
|||
|
|
@ -699,7 +699,8 @@
|
|||
"devSearchKeywordToast": "toast",
|
||||
"devSearchKeywordSonner": "sonner",
|
||||
"devSearchKeywordError": "error",
|
||||
"devSearchKeywordNotification": "notification"
|
||||
"devSearchKeywordNotification": "notification",
|
||||
"projectHostsSummary": "{{value0}} 个主机"
|
||||
},
|
||||
"useAppMenuPaste": {
|
||||
"pasteTooLarge": "粘贴内容过大。"
|
||||
|
|
@ -6075,7 +6076,7 @@
|
|||
"26fef02bf3": "项目图标",
|
||||
"c7ef4415de": "显示名称",
|
||||
"b0a0c14a1c": "侧边栏和选项卡的项目特定显示详细信息。",
|
||||
"170624bdfb": "从 Orca 中删除该项目。",
|
||||
"removeProjectAllHosts": "从所有已配置主机的 Orca 中删除该项目。",
|
||||
"0909e5d650": "删除项目",
|
||||
"ee5a290616": "作为文件夹打开。 Git 功能对此工作区不可用。",
|
||||
"323debba71": "类型:",
|
||||
|
|
|
|||
|
|
@ -368,6 +368,26 @@ describe('ensureHooksConfirmed', () => {
|
|||
expect(pending).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('forwards the explicit host to issueCommand inspection when repo ids collide', async () => {
|
||||
const { state } = createTestState({
|
||||
repos: [
|
||||
{ id: 'repo-1', displayName: 'Local Row' },
|
||||
{ id: 'repo-1', displayName: 'SSH Row', connectionId: 'server' }
|
||||
]
|
||||
} as unknown as Partial<AppState>)
|
||||
readIssueCommandMock.mockResolvedValue({
|
||||
source: 'local',
|
||||
sharedContent: null,
|
||||
localContent: 'user content',
|
||||
effectiveContent: 'user content',
|
||||
localFilePath: ''
|
||||
})
|
||||
|
||||
await ensureHooksConfirmed(state, 'repo-1', 'issueCommand', 'ssh:server')
|
||||
|
||||
expect(readIssueCommandMock).toHaveBeenCalledWith({ repoId: 'repo-1', hostId: 'ssh:server' })
|
||||
})
|
||||
|
||||
it('fails closed when issueCommand inspection reports an error status', async () => {
|
||||
const { state, pending } = createTestState()
|
||||
readIssueCommandMock.mockResolvedValue({
|
||||
|
|
|
|||
|
|
@ -101,9 +101,12 @@ export async function ensureHooksConfirmed(
|
|||
try {
|
||||
if (scriptKind === 'issueCommand') {
|
||||
// Local overrides are user-owned; only shared orca.yaml commands need repo trust.
|
||||
// Why: hostId disambiguates duplicate repo ids on the local IPC path,
|
||||
// matching the checkRuntimeHooks call below.
|
||||
const result = await readRuntimeIssueCommand(
|
||||
settingsForHookRepoOwner(state, repoId, hostId),
|
||||
repoId
|
||||
repoId,
|
||||
hostId
|
||||
)
|
||||
if (result.source === 'local') {
|
||||
return 'run'
|
||||
|
|
|
|||
|
|
@ -108,4 +108,35 @@ describe('runtime hooks client', () => {
|
|||
expect(hooksReadIssueCommand).not.toHaveBeenCalled()
|
||||
expect(hooksWriteIssueCommand).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('forwards an explicit SSH host to local hook IPC', async () => {
|
||||
hooksCheck.mockResolvedValue({ hasHooks: false, hooks: null, mayNeedUpdate: false })
|
||||
hooksReadIssueCommand.mockResolvedValue({
|
||||
localContent: null,
|
||||
sharedContent: null,
|
||||
effectiveContent: null,
|
||||
localFilePath: '',
|
||||
source: 'none'
|
||||
})
|
||||
|
||||
await checkRuntimeHooks({ activeRuntimeEnvironmentId: null }, 'same-repo', 'ssh:server')
|
||||
await readRuntimeIssueCommand({ activeRuntimeEnvironmentId: null }, 'same-repo', 'ssh:server')
|
||||
await writeRuntimeIssueCommand(
|
||||
{ activeRuntimeEnvironmentId: null },
|
||||
'same-repo',
|
||||
'Fix it',
|
||||
'ssh:server'
|
||||
)
|
||||
|
||||
expect(hooksCheck).toHaveBeenCalledWith({ repoId: 'same-repo', hostId: 'ssh:server' })
|
||||
expect(hooksReadIssueCommand).toHaveBeenCalledWith({
|
||||
repoId: 'same-repo',
|
||||
hostId: 'ssh:server'
|
||||
})
|
||||
expect(hooksWriteIssueCommand).toHaveBeenCalledWith({
|
||||
repoId: 'same-repo',
|
||||
content: 'Fix it',
|
||||
hostId: 'ssh:server'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -54,11 +54,12 @@ export async function inspectRuntimeSetupScriptImports(
|
|||
|
||||
export async function readRuntimeIssueCommand(
|
||||
settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined,
|
||||
repoId: string
|
||||
repoId: string,
|
||||
hostId?: ExecutionHostId
|
||||
): Promise<IssueCommandReadResult> {
|
||||
const target = getActiveRuntimeTarget(settings)
|
||||
if (target.kind !== 'environment') {
|
||||
return window.api.hooks.readIssueCommand({ repoId })
|
||||
return window.api.hooks.readIssueCommand({ repoId, ...(hostId ? { hostId } : {}) })
|
||||
}
|
||||
return callRuntimeRpc<IssueCommandReadResult>(
|
||||
target,
|
||||
|
|
@ -71,11 +72,12 @@ export async function readRuntimeIssueCommand(
|
|||
export async function writeRuntimeIssueCommand(
|
||||
settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined,
|
||||
repoId: string,
|
||||
content: string
|
||||
content: string,
|
||||
hostId?: ExecutionHostId
|
||||
): Promise<void> {
|
||||
const target = getActiveRuntimeTarget(settings)
|
||||
if (target.kind !== 'environment') {
|
||||
await window.api.hooks.writeIssueCommand({ repoId, content })
|
||||
await window.api.hooks.writeIssueCommand({ repoId, content, ...(hostId ? { hostId } : {}) })
|
||||
return
|
||||
}
|
||||
await callRuntimeRpc(
|
||||
|
|
|
|||
|
|
@ -1,19 +1,26 @@
|
|||
// @vitest-environment happy-dom
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { searchRuntimeRepoBaseRefDetails, searchRuntimeRepoBaseRefs } from './runtime-repo-client'
|
||||
import {
|
||||
getRuntimeRepoBaseRefDefault,
|
||||
searchRuntimeRepoBaseRefDetails,
|
||||
searchRuntimeRepoBaseRefs
|
||||
} from './runtime-repo-client'
|
||||
|
||||
const getBaseRefDefault = vi.fn()
|
||||
const searchBaseRefs = vi.fn()
|
||||
const searchBaseRefDetails = vi.fn()
|
||||
const runtimeCall = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
getBaseRefDefault.mockReset()
|
||||
searchBaseRefs.mockReset()
|
||||
searchBaseRefDetails.mockReset()
|
||||
runtimeCall.mockReset()
|
||||
vi.stubGlobal('window', {
|
||||
api: {
|
||||
repos: {
|
||||
getBaseRefDefault,
|
||||
searchBaseRefs,
|
||||
searchBaseRefDetails
|
||||
},
|
||||
|
|
@ -51,4 +58,23 @@ describe('runtime repo client search bounds', () => {
|
|||
expect(searchBaseRefDetails).not.toHaveBeenCalled()
|
||||
expect(runtimeCall).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('forwards the selected SSH host to base-ref IPC', async () => {
|
||||
getBaseRefDefault.mockResolvedValue({ defaultBaseRef: 'origin/main', remoteCount: 1 })
|
||||
searchBaseRefs.mockResolvedValue(['origin/main'])
|
||||
|
||||
await getRuntimeRepoBaseRefDefault(null, 'same-repo', 'ssh:server')
|
||||
await searchRuntimeRepoBaseRefs(null, 'same-repo', 'main', 20, 'ssh:server')
|
||||
|
||||
expect(getBaseRefDefault).toHaveBeenCalledWith({
|
||||
repoId: 'same-repo',
|
||||
hostId: 'ssh:server'
|
||||
})
|
||||
expect(searchBaseRefs).toHaveBeenCalledWith({
|
||||
repoId: 'same-repo',
|
||||
query: 'main',
|
||||
limit: 20,
|
||||
hostId: 'ssh:server'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import type { BaseRefSearchResult, GlobalSettings } from '../../../shared/types'
|
|||
import { legacyBaseRefSearchResult } from '../../../shared/base-ref-search-result'
|
||||
import { callRuntimeRpc, getActiveRuntimeTarget } from './runtime-rpc-client'
|
||||
import { isRuntimeRepoRefSearchQueryWithinLimit } from './runtime-repo-search-bounds'
|
||||
import type { ExecutionHostId } from '../../../shared/execution-host'
|
||||
|
||||
export type RuntimeRepoBaseRefDefault = {
|
||||
defaultBaseRef: string | null
|
||||
|
|
@ -10,11 +11,12 @@ export type RuntimeRepoBaseRefDefault = {
|
|||
|
||||
export async function getRuntimeRepoBaseRefDefault(
|
||||
settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined,
|
||||
repoId: string
|
||||
repoId: string,
|
||||
hostId?: ExecutionHostId
|
||||
): Promise<RuntimeRepoBaseRefDefault> {
|
||||
const target = getActiveRuntimeTarget(settings)
|
||||
if (target.kind !== 'environment') {
|
||||
return window.api.repos.getBaseRefDefault({ repoId })
|
||||
return window.api.repos.getBaseRefDefault({ repoId, ...(hostId ? { hostId } : {}) })
|
||||
}
|
||||
return callRuntimeRpc<RuntimeRepoBaseRefDefault>(
|
||||
target,
|
||||
|
|
@ -28,14 +30,15 @@ export async function searchRuntimeRepoBaseRefs(
|
|||
settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined,
|
||||
repoId: string,
|
||||
query: string,
|
||||
limit: number
|
||||
limit: number,
|
||||
hostId?: ExecutionHostId
|
||||
): Promise<string[]> {
|
||||
if (!isRuntimeRepoRefSearchQueryWithinLimit(query)) {
|
||||
return []
|
||||
}
|
||||
const target = getActiveRuntimeTarget(settings)
|
||||
if (target.kind !== 'environment') {
|
||||
return window.api.repos.searchBaseRefs({ repoId, query, limit })
|
||||
return window.api.repos.searchBaseRefs({ repoId, query, limit, ...(hostId ? { hostId } : {}) })
|
||||
}
|
||||
const result = await callRuntimeRpc<{ refs: string[]; truncated: boolean }>(
|
||||
target,
|
||||
|
|
@ -50,14 +53,20 @@ export async function searchRuntimeRepoBaseRefDetails(
|
|||
settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined,
|
||||
repoId: string,
|
||||
query: string,
|
||||
limit: number
|
||||
limit: number,
|
||||
hostId?: ExecutionHostId
|
||||
): Promise<BaseRefSearchResult[]> {
|
||||
if (!isRuntimeRepoRefSearchQueryWithinLimit(query)) {
|
||||
return []
|
||||
}
|
||||
const target = getActiveRuntimeTarget(settings)
|
||||
if (target.kind !== 'environment') {
|
||||
return window.api.repos.searchBaseRefDetails({ repoId, query, limit })
|
||||
return window.api.repos.searchBaseRefDetails({
|
||||
repoId,
|
||||
query,
|
||||
limit,
|
||||
...(hostId ? { hostId } : {})
|
||||
})
|
||||
}
|
||||
const result = await callRuntimeRpc<{
|
||||
refs: string[]
|
||||
|
|
|
|||
|
|
@ -133,6 +133,62 @@ describe('repo slice host identity routing', () => {
|
|||
)
|
||||
})
|
||||
|
||||
it('updateRepo with an explicit hostId routes to that host, not the focused one', async () => {
|
||||
runtimeEnvironmentCall.mockResolvedValue({
|
||||
id: 'rpc-explicit-update',
|
||||
ok: true,
|
||||
result: { repo: { ...remoteDuplicate, displayName: 'Remote via host' } },
|
||||
_meta: { runtimeId: 'runtime-remote' }
|
||||
})
|
||||
const store = createTestStore()
|
||||
// Focus is local (no active runtime env); without the explicit hostId this
|
||||
// would route to the focused (local) row.
|
||||
store.setState({ repos: [localDuplicate, remoteDuplicate] })
|
||||
|
||||
await store
|
||||
.getState()
|
||||
.updateRepo('same-repo', { displayName: 'Remote via host' }, { hostId: 'runtime:env-1' })
|
||||
|
||||
expect(runtimeEnvironmentCall).toHaveBeenCalledWith({
|
||||
selector: 'env-1',
|
||||
method: 'repo.update',
|
||||
params: { repo: 'same-repo', updates: { displayName: 'Remote via host' } },
|
||||
timeoutMs: 15_000
|
||||
})
|
||||
expect(reposUpdate).not.toHaveBeenCalled()
|
||||
expect(store.getState().repos).toEqual([
|
||||
localDuplicate,
|
||||
{ ...remoteDuplicate, displayName: 'Remote via host' }
|
||||
])
|
||||
})
|
||||
|
||||
it('updateRepo with an explicit local hostId stays local even when a runtime is focused', async () => {
|
||||
// The self-pair case: a repo id exists on both local and a focused runtime.
|
||||
// An explicit local hostId must route to local IPC, not the runtime RPC.
|
||||
reposUpdate.mockResolvedValue(undefined)
|
||||
const store = createTestStore()
|
||||
store.setState({
|
||||
settings: { activeRuntimeEnvironmentId: 'env-1' } as never,
|
||||
repos: [localDuplicate, remoteDuplicate]
|
||||
})
|
||||
|
||||
await store
|
||||
.getState()
|
||||
.updateRepo('same-repo', { displayName: 'Local via host' }, { hostId: 'local' })
|
||||
|
||||
expect(reposUpdate).toHaveBeenCalledWith({
|
||||
repoId: 'same-repo',
|
||||
updates: { displayName: 'Local via host' }
|
||||
})
|
||||
expect(runtimeEnvironmentCall).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ method: 'repo.update' })
|
||||
)
|
||||
expect(store.getState().repos).toEqual([
|
||||
{ ...localDuplicate, displayName: 'Local via host' },
|
||||
remoteDuplicate
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps queued focused-host repo updates pinned when focus changes', async () => {
|
||||
const firstUpdate = deferred<{
|
||||
id: string
|
||||
|
|
|
|||
|
|
@ -1497,7 +1497,13 @@ export type RepoSlice = {
|
|||
// id exists on multiple hosts; without it the focused host is assumed.
|
||||
removeProject: (projectId: string, options?: { hostId?: ExecutionHostId }) => Promise<void>
|
||||
updateProject: (projectId: string, updates: ProjectUpdate) => Promise<boolean>
|
||||
updateRepo: (projectId: string, updates: RepoUpdate) => Promise<boolean>
|
||||
// options.hostId targets a specific host's repo row + RPC target when the same
|
||||
// repo id exists on multiple hosts; without it the focused host is assumed.
|
||||
updateRepo: (
|
||||
projectId: string,
|
||||
updates: RepoUpdate,
|
||||
options?: { hostId?: ExecutionHostId }
|
||||
) => Promise<boolean>
|
||||
setActiveRepo: (projectId: string | null) => void
|
||||
reorderRepos: (orderedIds: string[]) => Promise<void>
|
||||
}
|
||||
|
|
@ -2978,14 +2984,22 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
|
|||
}
|
||||
},
|
||||
|
||||
updateRepo: async (projectId, updates) => {
|
||||
updateRepo: async (projectId, updates, options) => {
|
||||
const updateRepoChains = getRepoUpdateChains(get)
|
||||
const ownerRepo = findRepoForHost(get().repos, projectId, { settings: get().settings })
|
||||
// Why: pass options.hostId so a duplicate repo id across hosts resolves to the
|
||||
// intended row instead of findRepoForHost's settings-focused fallback.
|
||||
const ownerRepo = findRepoForHost(get().repos, projectId, {
|
||||
settings: get().settings,
|
||||
hostId: options?.hostId
|
||||
})
|
||||
if (!ownerRepo) {
|
||||
return false
|
||||
}
|
||||
// Why: an explicit hostId is authoritative — treat it as an explicit host so
|
||||
// routing goes to that host's target (local IPC or its runtime RPC) rather
|
||||
// than the currently-focused runtime, which is the same-id/self-pair case.
|
||||
const ownerHasExplicitHost = Boolean(
|
||||
ownerRepo.executionHostId?.trim() || ownerRepo.connectionId?.trim()
|
||||
options?.hostId || ownerRepo.executionHostId?.trim() || ownerRepo.connectionId?.trim()
|
||||
)
|
||||
const explicitOwnerHostId = getRepoExecutionHostId(ownerRepo)
|
||||
const ownerTarget = ownerHasExplicitHost
|
||||
|
|
|
|||
|
|
@ -984,6 +984,20 @@ describe('createUISlice hydratePersistedUI', () => {
|
|||
expect(store.getState().visibleWorkspaceHostIds).toBeNull()
|
||||
})
|
||||
|
||||
it('tracks the per-project settings host selection without persisting it', () => {
|
||||
const setUI = vi.fn(() => Promise.resolve())
|
||||
vi.stubGlobal('window', { api: { ui: { set: setUI } } })
|
||||
const store = createUIStore()
|
||||
|
||||
store.getState().setSettingsProjectHostSelection('git:acme/app', 'runtime:home-mac')
|
||||
|
||||
expect(store.getState().settingsProjectHostSelection).toEqual({
|
||||
'git:acme/app': 'runtime:home-mac'
|
||||
})
|
||||
// Ephemeral: never written through the UI persistence pipeline.
|
||||
expect(setUI).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('persists workspace host scope changes', () => {
|
||||
const setUI = vi.fn(() => Promise.resolve())
|
||||
vi.stubGlobal('window', { api: { ui: { set: setUI } } })
|
||||
|
|
|
|||
|
|
@ -771,6 +771,14 @@ export type UISlice = {
|
|||
} | null
|
||||
openSettingsTarget: (target: NonNullable<UISlice['settingsNavigationTarget']>) => void
|
||||
clearSettingsTarget: () => void
|
||||
/**
|
||||
* Which host the Projects Settings pane shows for each project, keyed by
|
||||
* projectId. Set by the pane's "Available Hosts" switcher. Ephemeral on
|
||||
* purpose — never persisted, so a reload reopens on the project's effective
|
||||
* host rather than a possibly-dangling selection.
|
||||
*/
|
||||
settingsProjectHostSelection: Record<string, ExecutionHostId>
|
||||
setSettingsProjectHostSelection: (projectId: string, hostId: ExecutionHostId) => void
|
||||
/**
|
||||
* One-shot Appearance accordion to expand for nested Settings deep links
|
||||
* (e.g. Usage percentages lives under Window & Sidebar). Cleared when
|
||||
|
|
@ -1533,6 +1541,20 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set, get)
|
|||
settingsNavigationTarget: null,
|
||||
openSettingsTarget: (target) => set({ settingsNavigationTarget: target }),
|
||||
clearSettingsTarget: () => set({ settingsNavigationTarget: null }),
|
||||
settingsProjectHostSelection: {},
|
||||
// Why: renderer-only, never persisted — no window.api.ui.set here and this
|
||||
// field is intentionally absent from the debounced UI writer in App.tsx.
|
||||
setSettingsProjectHostSelection: (projectId, hostId) =>
|
||||
set((s) =>
|
||||
s.settingsProjectHostSelection[projectId] === hostId
|
||||
? s
|
||||
: {
|
||||
settingsProjectHostSelection: {
|
||||
...s.settingsProjectHostSelection,
|
||||
[projectId]: hostId
|
||||
}
|
||||
}
|
||||
),
|
||||
appearanceAccordionDeepLink: null,
|
||||
setAppearanceAccordionDeepLink: (section) => set({ appearanceAccordionDeepLink: section }),
|
||||
clearAppearanceAccordionDeepLink: () => set({ appearanceAccordionDeepLink: null }),
|
||||
|
|
|
|||
Loading…
Reference in New Issue