From 55e6f4bfcdb8f38e93f5aa286d7b972b91fbbd00 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 21 May 2026 16:33:30 -0700 Subject: [PATCH] Improve setup settings organization (#2503) * fix: address review findings * fix: address review findings --- src/main/hooks.test.ts | 65 +- src/main/hooks.ts | 51 +- src/main/ipc/worktree-remote.ts | 4 +- .../startup/run-electron-vite-dev.test.ts | 5 +- .../settings/RepositoryHooksSection.test.ts | 95 +- .../settings/RepositoryHooksSection.tsx | 1097 ++++++++++------- .../settings/RepositoryPane.test.ts | 27 + .../components/settings/RepositoryPane.tsx | 145 ++- .../src/components/settings/Settings.tsx | 3 +- .../sidebar/SetupScriptPromptCard.tsx | 12 +- .../src/lib/ensure-hooks-confirmed.test.ts | 26 + .../src/lib/ensure-hooks-confirmed.ts | 10 +- src/renderer/src/lib/new-workspace.ts | 6 +- src/shared/constants.ts | 1 - src/shared/hook-command-source-policy.test.ts | 26 + src/shared/hook-command-source-policy.ts | 15 + tests/e2e/setup-script-import.spec.ts | 52 +- 17 files changed, 1029 insertions(+), 611 deletions(-) create mode 100644 src/renderer/src/components/settings/RepositoryPane.test.ts create mode 100644 src/shared/hook-command-source-policy.test.ts diff --git a/src/main/hooks.test.ts b/src/main/hooks.test.ts index a6ce1718c9d..b57e4ea3e34 100644 --- a/src/main/hooks.test.ts +++ b/src/main/hooks.test.ts @@ -380,7 +380,7 @@ describe('getEffectiveHooks', () => { expect(result).toBeNull() }) - it('uses shared yaml settings over local settings by default', async () => { + it('uses local settings over shared yaml settings by default when local hooks exist', async () => { const fs = await import('fs') vi.mocked(fs.existsSync).mockReturnValue(true) vi.mocked(fs.readFileSync).mockReturnValue('scripts:\n setup: |\n echo "yaml setup"\n') @@ -394,7 +394,7 @@ describe('getEffectiveHooks', () => { expect(result).toEqual({ scripts: { - setup: 'echo "yaml setup"' + setup: 'echo "ui override"' } }) }) @@ -439,7 +439,7 @@ describe('getEffectiveHooks', () => { }) }) - it('treats orca.yaml as authoritative by default when it defines only one command', async () => { + it('uses local settings by default even when orca.yaml defines only one command', async () => { const fs = await import('fs') vi.mocked(fs.existsSync).mockReturnValue(true) vi.mocked(fs.readFileSync).mockReturnValue('scripts:\n archive: |\n echo "yaml archive"\n') @@ -453,12 +453,35 @@ describe('getEffectiveHooks', () => { expect(result).toEqual({ scripts: { - archive: 'echo "yaml archive"' + setup: 'echo "legacy setup"', + archive: 'echo "legacy archive"' } }) }) - it('does not fall back to legacy local hooks when yaml exists without supported hooks', async () => { + it('keeps shared setup when only archive has a legacy local script', async () => { + const fs = await import('fs') + vi.mocked(fs.existsSync).mockReturnValue(true) + vi.mocked(fs.readFileSync).mockReturnValue( + 'scripts:\n setup: |\n echo "yaml setup"\n archive: |\n echo "yaml archive"\n' + ) + + const { getEffectiveHooks } = await import('./hooks') + const repo = makeRepo({ + mode: 'override', + scripts: { setup: '', archive: 'echo "legacy archive"' } + }) + const result = getEffectiveHooks(repo) + + expect(result).toEqual({ + scripts: { + setup: 'echo "yaml setup"', + archive: 'echo "legacy archive"' + } + }) + }) + + it('uses local settings by default when yaml exists without supported hooks', async () => { const fs = await import('fs') vi.mocked(fs.existsSync).mockReturnValue(true) vi.mocked(fs.readFileSync).mockReturnValue('futureFeature: enabled\n') @@ -470,7 +493,12 @@ describe('getEffectiveHooks', () => { }) const result = getEffectiveHooks(repo) - expect(result).toBeNull() + expect(result).toEqual({ + scripts: { + setup: 'echo "legacy setup"', + archive: 'echo "legacy archive"' + } + }) }) it('treats legacy shared-first policy as orca.yaml only', async () => { @@ -518,7 +546,7 @@ describe('getEffectiveHooks', () => { expect(result).toEqual({ source: 'local', command: 'echo "legacy setup"' }) }) - it('does not use legacy local setup source when yaml omits setup', async () => { + it('uses local setup source by default when yaml omits setup', async () => { const fs = await import('fs') vi.mocked(fs.existsSync).mockReturnValue(true) vi.mocked(fs.readFileSync).mockReturnValue('scripts:\n archive: |\n echo "yaml archive"\n') @@ -530,10 +558,10 @@ describe('getEffectiveHooks', () => { }) const result = getSetupCommandSource(repo) - expect(result).toBeNull() + expect(result).toEqual({ source: 'local', command: 'echo "legacy setup"' }) }) - it('does not use legacy local setup source when yaml exists without supported hooks', async () => { + it('uses local setup source by default when yaml exists without supported hooks', async () => { const fs = await import('fs') vi.mocked(fs.existsSync).mockReturnValue(true) vi.mocked(fs.readFileSync).mockReturnValue('futureFeature: enabled\n') @@ -545,7 +573,24 @@ describe('getEffectiveHooks', () => { }) const result = getSetupCommandSource(repo) - expect(result).toBeNull() + expect(result).toEqual({ source: 'local', command: 'echo "legacy setup"' }) + }) + + it('uses shared setup source when only archive has a legacy local script', async () => { + const fs = await import('fs') + vi.mocked(fs.existsSync).mockReturnValue(true) + vi.mocked(fs.readFileSync).mockReturnValue( + 'scripts:\n setup: |\n echo "yaml setup"\n archive: |\n echo "yaml archive"\n' + ) + + const { getSetupCommandSource } = await import('./hooks') + const repo = makeRepo({ + mode: 'override', + scripts: { setup: '', archive: 'echo "legacy archive"' } + }) + const result = getSetupCommandSource(repo) + + expect(result).toEqual({ source: 'yaml', command: 'echo "yaml setup"' }) }) }) diff --git a/src/main/hooks.ts b/src/main/hooks.ts index 3b0df6b6283..0b33a81bcad 100644 --- a/src/main/hooks.ts +++ b/src/main/hooks.ts @@ -4,7 +4,7 @@ import { dirname, join } from 'path' import { exec, execFile } from 'child_process' import { getDefaultRepoHookSettings } from '../shared/constants' import { getRuntimePathBasename } from '../shared/cross-platform-path' -import { normalizeHookCommandSourcePolicy } from '../shared/hook-command-source-policy' +import { resolveHookCommandSourcePolicy } from '../shared/hook-command-source-policy' import { gitExecFileSync } from './git/runner' import { isWslPath, parseWslPath, toWindowsWslPath, toLinuxPath } from './wsl' import type { @@ -267,8 +267,7 @@ function ensureOrcaDirIgnored(repoPath: string): void { function getEffectiveHookScript( yamlScript: string | undefined, localScript: string | undefined, - policy: HookCommandSourcePolicy, - legacyFallback: boolean + policy: HookCommandSourcePolicy ): string | undefined { const shared = yamlScript?.trim() const local = localScript?.trim() @@ -281,39 +280,24 @@ function getEffectiveHookScript( return [shared, local].filter(Boolean).join('\n') || undefined } - // Why: existing users persisted local setup/archive scripts before - // commandSourcePolicy existed. Without a fallback the new default - // 'shared-only' would silently drop those scripts when orca.yaml is missing. - if (legacyFallback) { - return shared || local || undefined - } - return shared || undefined } -function hasLegacyLocalScripts(repo: Repo): boolean { - return Boolean( - repo.hookSettings?.scripts.setup?.trim() || repo.hookSettings?.scripts.archive?.trim() - ) -} - export function getEffectiveHooksFromConfig( repo: Repo, - yamlHooks: OrcaHooks | null, - yamlFileExists: boolean + yamlHooks: OrcaHooks | null ): OrcaHooks | null { const localSetup = repo.hookSettings?.scripts.setup const localArchive = repo.hookSettings?.scripts.archive const rawPolicy = repo.hookSettings?.commandSourcePolicy - const policy = normalizeHookCommandSourcePolicy(rawPolicy) - const legacyFallback = rawPolicy === undefined && !yamlFileExists && hasLegacyLocalScripts(repo) - const setup = getEffectiveHookScript(yamlHooks?.scripts.setup, localSetup, policy, legacyFallback) - const archive = getEffectiveHookScript( - yamlHooks?.scripts.archive, - localArchive, - policy, - legacyFallback - ) + const setupPolicy = resolveHookCommandSourcePolicy(rawPolicy, { + hasLocalScript: Boolean(localSetup?.trim()) + }) + const archivePolicy = resolveHookCommandSourcePolicy(rawPolicy, { + hasLocalScript: Boolean(localArchive?.trim()) + }) + const setup = getEffectiveHookScript(yamlHooks?.scripts.setup, localSetup, setupPolicy) + const archive = getEffectiveHookScript(yamlHooks?.scripts.archive, localArchive, archivePolicy) if (!setup && !archive) { return null @@ -332,7 +316,7 @@ export function getEffectiveHooksFromConfig( export function getEffectiveHooks(repo: Repo, worktreePath?: string): OrcaHooks | null { const hooksRoot = worktreePath ?? repo.path - return getEffectiveHooksFromConfig(repo, loadHooks(hooksRoot), hasHooksFile(hooksRoot)) + return getEffectiveHooksFromConfig(repo, loadHooks(hooksRoot)) } export function getEffectiveSetupRunPolicy(repo: Repo): SetupRunPolicy { @@ -361,11 +345,12 @@ export function getSetupCommandSource( ): { source: 'yaml' | 'local' | 'both'; command: string } | null { const hooksRoot = worktreePath ?? repo.path const yamlHooks = loadHooks(hooksRoot) - const yamlFileExists = hasHooksFile(hooksRoot) const yamlSetup = yamlHooks?.scripts.setup?.trim() const localSetup = repo.hookSettings?.scripts.setup?.trim() const rawPolicy = repo.hookSettings?.commandSourcePolicy - const policy = normalizeHookCommandSourcePolicy(rawPolicy) + const policy = resolveHookCommandSourcePolicy(rawPolicy, { + hasLocalScript: Boolean(localSetup) + }) if (policy === 'local-only') { return localSetup ? { source: 'local', command: localSetup } : null @@ -379,12 +364,6 @@ export function getSetupCommandSource( return { source: 'yaml', command: yamlSetup } } - // Why: pre-policy persisted local setup scripts must keep working when - // orca.yaml is missing; otherwise upgrading silently drops the legacy hook. - if (rawPolicy === undefined && !yamlFileExists && localSetup && hasLegacyLocalScripts(repo)) { - return { source: 'local', command: localSetup } - } - return null } diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index 98512548211..d374eb80c23 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -550,9 +550,9 @@ async function readRemoteEffectiveHooks( try { const result = await fsProvider.readFile(joinWorktreeRelativePath(hooksRootPath, 'orca.yaml')) const yamlHooks = result.isBinary ? null : parseOrcaYaml(result.content) - return getEffectiveHooksFromConfig(repo, yamlHooks, true) + return getEffectiveHooksFromConfig(repo, yamlHooks) } catch { - return getEffectiveHooksFromConfig(repo, null, false) + return getEffectiveHooksFromConfig(repo, null) } } diff --git a/src/main/startup/run-electron-vite-dev.test.ts b/src/main/startup/run-electron-vite-dev.test.ts index cd448332291..d8ed9e67baa 100644 --- a/src/main/startup/run-electron-vite-dev.test.ts +++ b/src/main/startup/run-electron-vite-dev.test.ts @@ -344,7 +344,7 @@ describe('run-electron-vite-dev', () => { } catch { return false } - }) + }, 20000) const grandchildPid = Number.parseInt(readFileSync(pidFile, 'utf8').trim(), 10) if (Number.isFinite(grandchildPid)) { @@ -366,6 +366,7 @@ describe('run-electron-vite-dev', () => { expect(readlinkSync(join(frameworkPath, 'Versions', 'Current'))).toBe('A') wrapper.kill('SIGINT') - } + }, + 30000 ) }) diff --git a/src/renderer/src/components/settings/RepositoryHooksSection.test.ts b/src/renderer/src/components/settings/RepositoryHooksSection.test.ts index 0f290070623..ac4bb19b878 100644 --- a/src/renderer/src/components/settings/RepositoryHooksSection.test.ts +++ b/src/renderer/src/components/settings/RepositoryHooksSection.test.ts @@ -1,41 +1,74 @@ import { describe, expect, it } from 'vitest' -import { - commandRowsToScript, - localCommandDraftToScripts, - scriptToCommandRows, - type LocalCommandDraft, - type LocalCommandRow -} from './RepositoryHooksSection' +import { getLocalCommandSourcePolicyNotice } from './RepositoryHooksSection' -describe('RepositoryHooksSection command row serialization', () => { - it('round-trips blank lines and trailing whitespace in existing scripts', () => { - const script = 'echo before \n\ncat < { + it('does not show a notice when no local scripts are saved', () => { + expect( + getLocalCommandSourcePolicyNotice({ + hooksInspectionReady: true, + currentPolicy: 'shared-only', + setupScript: '', + archiveScript: '', + hasSharedScript: false + }) + ).toBeNull() }) - it('keeps persisted blank rows distinct from new empty placeholders', () => { - const rows: LocalCommandRow[] = [ - ...scriptToCommandRows('echo before\n\n echo after '), - { value: '', isPlaceholder: true } - ] + it('does not show a notice when command source already includes local scripts', () => { + expect( + getLocalCommandSourcePolicyNotice({ + hooksInspectionReady: true, + currentPolicy: 'local-only', + setupScript: 'pnpm install', + archiveScript: '', + hasSharedScript: true + }) + ).toBeNull() - expect(commandRowsToScript(rows)).toBe('echo before\n\n echo after ') + expect( + getLocalCommandSourcePolicyNotice({ + hooksInspectionReady: true, + currentPolicy: 'run-both', + setupScript: '', + archiveScript: 'echo archive', + hasSharedScript: true + }) + ).toBeNull() }) - it('serializes local command drafts with the same placeholder pruning used by commits', () => { - const draft: LocalCommandDraft = { - setup: [...scriptToCommandRows('echo setup\n'), { value: '', isPlaceholder: true }], - archive: [ - { value: '', isPlaceholder: false }, - { value: 'echo archive', isPlaceholder: false }, - { value: '', isPlaceholder: true } - ] - } + it('waits for hook inspection before recommending a command source', () => { + expect( + getLocalCommandSourcePolicyNotice({ + hooksInspectionReady: false, + currentPolicy: 'shared-only', + setupScript: 'pnpm install', + archiveScript: '', + hasSharedScript: false + }) + ).toEqual({ kind: 'checking' }) + }) - expect(localCommandDraftToScripts(draft)).toEqual({ - setup: 'echo setup\n', - archive: '\necho archive' - }) + it('recommends local commands when local scripts are saved and no shared script exists', () => { + expect( + getLocalCommandSourcePolicyNotice({ + hooksInspectionReady: true, + currentPolicy: 'shared-only', + setupScript: 'pnpm install', + archiveScript: '', + hasSharedScript: false + }) + ).toEqual({ kind: 'action', policy: 'local-only', label: 'Use local commands' }) + }) + + it('recommends run-both when local and shared scripts both exist', () => { + expect( + getLocalCommandSourcePolicyNotice({ + hooksInspectionReady: true, + currentPolicy: 'shared-only', + setupScript: '', + archiveScript: 'echo archive', + hasSharedScript: true + }) + ).toEqual({ kind: 'action', policy: 'run-both', label: 'Run both' }) }) }) diff --git a/src/renderer/src/components/settings/RepositoryHooksSection.tsx b/src/renderer/src/components/settings/RepositoryHooksSection.tsx index 18341ea9583..421338fb243 100644 --- a/src/renderer/src/components/settings/RepositoryHooksSection.tsx +++ b/src/renderer/src/components/settings/RepositoryHooksSection.tsx @@ -1,4 +1,4 @@ -/* eslint-disable max-lines -- Why: the YAML status card, issue-command editor, policy grid, and legacy-hook section form one cohesive settings surface; splitting them across files would scatter tightly coupled state and prop drilling. */ +/* 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. */ import { useCallback, useEffect, useRef, useState } from 'react' import type { HookCommandSourcePolicy, @@ -7,21 +7,23 @@ import type { RepoHookSettings, SetupRunPolicy } from '../../../../shared/types' -import { AlertTriangle, Plus, Trash2 } from 'lucide-react' +import { AlertTriangle, ChevronRight, Plus } from 'lucide-react' import { toast } from 'sonner' import { Button } from '../ui/button' -import { Input } from '../ui/input' +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip' import { SearchableSetting } from './SearchableSetting' import { useAppStore } from '@/store' import { readRuntimeIssueCommand, writeRuntimeIssueCommand } from '@/runtime/runtime-hooks-client' import { DEFAULT_REPO_HOOK_SETTINGS } from './SettingsConstants' -import { normalizeHookCommandSourcePolicy } from '../../../../shared/hook-command-source-policy' +import { resolveHookCommandSourcePolicy } from '../../../../shared/hook-command-source-policy' import { getRepositoryLocalCommandsSectionId } from './repository-settings-targets' +import { matchesSettingsSearch } from './settings-search' type RepositoryHooksSectionProps = { repo: Repo yamlHooks: OrcaHooks | null hasHooksFile: boolean + hooksInspectionReady: boolean mayNeedUpdate: boolean copiedTemplate: boolean onCopyTemplate: () => void @@ -29,10 +31,8 @@ type RepositoryHooksSectionProps = { } type PolicyOption

= { policy: P; label: string; description: string } -export type LocalCommandRow = { value: string; isPlaceholder: boolean } const LOCAL_HOOK_NAMES = ['setup', 'archive'] as const type LocalHookName = (typeof LOCAL_HOOK_NAMES)[number] -export type LocalCommandDraft = Record type HookSettingsPolicyDraft = Partial< Pick > @@ -50,69 +50,74 @@ const SETUP_RUN_POLICY_OPTIONS: PolicyOption[] = [ const COMMAND_SOURCE_POLICY_OPTIONS: PolicyOption[] = [ { policy: 'shared-only', - label: 'Use orca.yaml only', - description: 'Run only committed repo commands; ignore local Settings commands.' + label: 'orca.yaml only', + description: 'Run only committed repo commands; ignore local commands.' }, { policy: 'local-only', - label: 'Use local only', - description: 'Ignore repo commands and run only your local Settings commands.' + label: 'Local only', + description: 'Ignore orca.yaml; run only your local commands.' }, { policy: 'run-both', label: 'Run both', - description: 'Run orca.yaml first, then your local Settings command.' + description: 'orca.yaml first, then your local commands.' } ] -const LOCAL_HOOK_FIELDS: { +const COMMAND_SOURCE_LABEL: Record = { + 'shared-only': 'orca.yaml only', + 'local-only': 'Local only', + 'run-both': 'Run both' +} + +type LocalHookField = { name: LocalHookName label: string description: string placeholder: string -}[] = [ +} + +const LOCAL_HOOK_FIELDS: LocalHookField[] = [ { name: 'setup', - label: 'Local setup command', - description: 'Runs after a new workspace is created when the source policy includes local.', - placeholder: 'cp "$ORCA_ROOT_PATH/.env" "$ORCA_WORKTREE_PATH/.env"' + label: 'Setup Script', + description: + 'Runs after a new worktree is created; install deps, copy env files, run migrations.', + placeholder: '# e.g.\npnpm install\ncp "$ORCA_ROOT_PATH/.env" "$ORCA_WORKTREE_PATH/.env"' }, { name: 'archive', - label: 'Local archive command', - description: 'Runs before a local workspace is archived or removed.', - placeholder: 'echo "Cleaning up $ORCA_WORKSPACE_NAME"' + label: 'Archive Script', + description: 'Runs before a worktree is archived or removed.', + placeholder: '# e.g.\necho "Cleaning up $ORCA_WORKSPACE_NAME"' } ] -export function scriptToCommandRows(script: string | undefined): LocalCommandRow[] { - if (!script) { - return [] +const ENV_VARS: readonly { name: string; description: string }[] = [ + { + name: '$ORCA_ROOT_PATH', + description: + "The main repo's path on disk - useful for copying files (e.g. .env) into the worktree." + }, + { + name: '$ORCA_WORKTREE_PATH', + description: + "The new worktree's path - where setup commands run and where files should be copied to." + }, + { + name: '$ORCA_WORKSPACE_NAME', + description: 'The workspace (branch) name for this worktree.' } +] - return script.split('\n').map((line) => ({ - value: line.endsWith('\r') ? line.slice(0, -1) : line, - isPlaceholder: false - })) -} - -export function commandRowsToScript(commands: LocalCommandRow[]): string { - return commands - .filter((command) => !(command.isPlaceholder && command.value.length === 0)) - .map((command) => command.value) - .join('\n') -} - -function pruneLocalCommandPlaceholders(commands: LocalCommandRow[]): LocalCommandRow[] { - return commands.filter((command) => !(command.isPlaceholder && command.value.length === 0)) -} - -export function localCommandDraftToScripts(draft: LocalCommandDraft): RepoHookSettings['scripts'] { - return { - setup: commandRowsToScript(pruneLocalCommandPlaceholders(draft.setup)), - archive: commandRowsToScript(pruneLocalCommandPlaceholders(draft.archive)) - } -} +const EXAMPLE_TEMPLATE = `scripts: + setup: | + pnpm worktree:setup + archive: | + echo "Cleaning up before archive" +issueCommand: | + Complete {{artifact_url}}` function getHookSettingsDraft(hookSettings: Repo['hookSettings']): RepoHookSettings { return { @@ -125,21 +130,46 @@ function getHookSettingsDraft(hookSettings: Repo['hookSettings']): RepoHookSetti } } -function getLocalCommandsDraft(hookSettings: Repo['hookSettings']): LocalCommandDraft { - const draft = getHookSettingsDraft(hookSettings) - return { - setup: scriptToCommandRows(draft.scripts.setup), - archive: scriptToCommandRows(draft.scripts.archive) - } +function areHookSettingsDraftsEqual(a: RepoHookSettings, b: RepoHookSettings): boolean { + return ( + a.mode === b.mode && + a.setupRunPolicy === b.setupRunPolicy && + a.commandSourcePolicy === b.commandSourcePolicy && + a.scripts.setup === b.scripts.setup && + a.scripts.archive === b.scripts.archive + ) } -const EXAMPLE_TEMPLATE = `scripts: - setup: | - pnpm worktree:setup - archive: | - echo "Cleaning up before archive" -issueCommand: | - Complete {{artifact_url}}` +export type LocalCommandSourcePolicyNotice = + | { kind: 'checking' } + | { kind: 'action'; policy: 'local-only' | 'run-both'; label: string } + +export function getLocalCommandSourcePolicyNotice({ + hooksInspectionReady, + currentPolicy, + setupScript, + archiveScript, + hasSharedScript +}: { + hooksInspectionReady: boolean + currentPolicy: HookCommandSourcePolicy + setupScript: string | undefined + archiveScript: string | undefined + hasSharedScript: boolean +}): LocalCommandSourcePolicyNotice | null { + if (!setupScript?.trim() && !archiveScript?.trim()) { + return null + } + if (currentPolicy !== 'shared-only') { + return null + } + if (!hooksInspectionReady) { + return { kind: 'checking' } + } + return hasSharedScript + ? { kind: 'action', policy: 'run-both', label: 'Run both' } + : { kind: 'action', policy: 'local-only', label: 'Use local commands' } +} const YAML_STATE_STYLES: Record< string, @@ -175,7 +205,6 @@ const YAML_STATE_STYLES: Record< } } -/** Shared button grid for setup run-policy selectors. */ function PolicyOptionGrid

({ options, selected, @@ -193,6 +222,7 @@ function PolicyOptionGrid

({ const active = selected === policy return ( + ) + })} + + ) +} + function ExampleTemplateCard({ copiedTemplate, onCopyTemplate @@ -248,19 +311,253 @@ function ExampleTemplateCard({ ) } +function YamlScriptBlock({ content }: { content: string }): React.JSX.Element { + return ( +

+      {content}
+    
+ ) +} + +function EnvVarChips(): React.JSX.Element { + return ( +
+

+ Available environment variables (hover for details): +

+ +
+ {ENV_VARS.map(({ name, description }) => ( + + + + {name} + + + + {description} + + + ))} +
+
+
+ ) +} + +type SaveStatus = 'idle' | 'saving' | 'saved' + +function SaveIndicator({ status }: { status: SaveStatus }): React.JSX.Element | null { + if (status === 'idle') { + return null + } + const isSaving = status === 'saving' + return ( + + + {isSaving ? 'Saving...' : 'Saved'} + + ) +} + +function LocalCommandSourceNotice({ + notice, + onSelectPolicy +}: { + notice: LocalCommandSourcePolicyNotice + onSelectPolicy: (policy: 'local-only' | 'run-both') => void +}): React.JSX.Element { + const isChecking = notice.kind === 'checking' + return ( +
+
+ +
+

+ Local scripts will not run +

+

+ {isChecking + ? 'Local scripts are saved. Orca is still checking orca.yaml before it can recommend which script source to use.' + : 'Local scripts are saved, but Script Source is set to orca.yaml only.'} +

+
+
+ {notice.kind === 'action' ? ( + + ) : ( + + Checking... + + )} +
+ ) +} + +type ScriptEditorProps = { + field: LocalHookField + value: string + hasShared: boolean + sharedScript: string | undefined + onChange: (next: string) => void + onCommit: () => void + sectionId?: string +} + +function ScriptEditor({ + field, + value, + hasShared, + sharedScript, + onChange, + onCommit, + sectionId +}: ScriptEditorProps): React.JSX.Element { + const [showLocal, setShowLocal] = useState(value.length > 0) + const [saveStatus, setSaveStatus] = useState('idle') + const lastValueRef = useRef(value) + const savedTimerRef = useRef(null) + + useEffect(() => { + if (value === lastValueRef.current) { + return + } + lastValueRef.current = value + setSaveStatus('saving') + if (savedTimerRef.current !== null) { + window.clearTimeout(savedTimerRef.current) + } + // Why: persistence is synchronous from the editor's POV, but we briefly + // show "Saving..." then "Saved" so the indicator carries the auto-save trust + // signal a Save button would (without the click). + savedTimerRef.current = window.setTimeout(() => { + setSaveStatus('saved') + savedTimerRef.current = window.setTimeout(() => { + setSaveStatus('idle') + savedTimerRef.current = null + }, 1500) + }, 250) + return () => { + if (savedTimerRef.current !== null) { + window.clearTimeout(savedTimerRef.current) + savedTimerRef.current = null + } + } + }, [value]) + + useEffect(() => { + // Why: when the repo or its persisted local script changes (e.g. switching repos), + // re-evaluate whether the local block should be visible by default. + if (value.length > 0) { + setShowLocal(true) + } + }, [value]) + + const showLocalEditor = showLocal || !hasShared + const lineCount = value ? value.split('\n').length : 0 + + return ( +
+
+
{field.label}
+

{field.description}

+
+ + + + {hasShared ? ( +
+
+ + orca.yaml + + - shared with your team + + + + Edit orca.yaml to change. + +
+ +
+ ) : null} + + {showLocalEditor ? ( +
+
+ {hasShared ? ( + + local + - just for you, on this machine + + ) : ( + + )} + +
+