diff --git a/src/main/editor/format-editor-text.test.ts b/src/main/editor/format-editor-text.test.ts new file mode 100644 index 00000000000..372346825e2 --- /dev/null +++ b/src/main/editor/format-editor-text.test.ts @@ -0,0 +1,142 @@ +import { mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { runProcess } from '@orca/process-host' +import type * as ProcessHost from '@orca/process-host' +import type { Repo } from '../../shared/repo-types' +import { DEFAULT_FORMATTER_INCLUDE } from '../../shared/editor-formatter' +import { formatEditorText } from './format-editor-text' + +vi.mock('@orca/process-host', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, runProcess: vi.fn(actual.runProcess) } +}) + +describe('project formatter execution', () => { + let root: string + let repo: Repo + const store = { getRepos: () => [repo] } + const target = () => ({ + path: join(root, 'a.ts'), + worktree: { path: root, repoId: 'repo' }, + executionHostId: 'local' as const + }) + const command = (script: string): string => `"${process.execPath}" -e '${script}'` + + beforeEach(async () => { + vi.clearAllMocks() + root = await mkdtemp(join(tmpdir(), 'orca-formatter-')) + await writeFile(join(root, 'a.ts'), 'disk baseline') + repo = { + id: 'repo', + path: root, + displayName: 'Project', + badgeColor: '', + addedAt: 0, + kind: 'folder', + editorFormatter: { + command: command( + 'let s="";process.stdin.setEncoding("utf8");process.stdin.on("data",x=>s+=x);process.stdin.on("end",()=>process.stdout.write(s.trim()+"\\n"))' + ), + include: [DEFAULT_FORMATTER_INCLUDE] + } + } + }) + + afterEach(async () => { + await rm(root, { recursive: true, force: true }) + }) + + it('formats the buffer through stdin without changing disk, using the workspace cwd and path', async () => { + expect(await formatEditorText(store, target(), ' const a=1; ')).toEqual({ + kind: 'formatted', + text: 'const a=1;\n' + }) + expect(await readFile(target().path, 'utf8')).toBe('disk baseline') + expect(runProcess).toHaveBeenCalledWith( + expect.objectContaining({ cwd: root, input: ' const a=1; ' }) + ) + }) + + it('does not spawn for unconfigured projects, other hosts, or excluded files', async () => { + expect( + await formatEditorText(store, { ...target(), path: join(root, 'a.png') }, 'text') + ).toEqual({ kind: 'skipped' }) + expect( + await formatEditorText(store, { ...target(), executionHostId: 'ssh:other' }, 'text') + ).toEqual({ kind: 'skipped' }) + delete repo.editorFormatter + expect(await formatEditorText(store, target(), 'text')).toEqual({ kind: 'skipped' }) + expect(runProcess).not.toHaveBeenCalled() + }) + + it('checks host support only after a configured formatter applies', async () => { + const unsupported = vi.fn(() => { + throw new Error('unsupported host') + }) + expect( + await formatEditorText(store, { ...target(), path: join(root, 'a.png') }, 'text', unsupported) + ).toEqual({ kind: 'skipped' }) + expect(unsupported).not.toHaveBeenCalled() + await expect(formatEditorText(store, target(), 'text', unsupported)).rejects.toThrow( + 'unsupported host' + ) + expect(runProcess).not.toHaveBeenCalled() + }) + + it('reports the formatter failure and never changes the saved file', async () => { + repo.editorFormatter!.command = command('process.stderr.write("syntax error");process.exit(2)') + await expect(formatEditorText(store, target(), 'input')).rejects.toThrow('syntax error') + expect(await readFile(target().path, 'utf8')).toBe('disk baseline') + }) + + it('refuses a successful command that would erase a non-empty buffer', async () => { + repo.editorFormatter!.command = command('process.exit(0)') + await expect(formatEditorText(store, target(), 'const x = 1')).rejects.toThrow( + 'returned no text' + ) + expect(await readFile(target().path, 'utf8')).toBe('disk baseline') + }) + + it('formats a new file without creating it before the guarded save', async () => { + const path = join(root, 'new.ts') + expect(await formatEditorText(store, { ...target(), path }, ' new ')).toEqual({ + kind: 'formatted', + text: 'new\n' + }) + await expect(readFile(path)).rejects.toMatchObject({ code: 'ENOENT' }) + }) + + it.skipIf(process.platform === 'win32')( + 'rejects a linked file outside the workspace before starting the formatter', + async () => { + const outside = await mkdtemp(join(tmpdir(), 'orca-formatter-outside-')) + try { + await writeFile(join(outside, 'outside.ts'), 'outside') + await symlink(join(outside, 'outside.ts'), join(root, 'linked.ts')) + await expect( + formatEditorText(store, { ...target(), path: join(root, 'linked.ts') }, 'input') + ).rejects.toThrow('outside its workspace') + expect(runProcess).not.toHaveBeenCalled() + } finally { + await rm(outside, { recursive: true, force: true }) + } + } + ) + + it.each([ + [{ timedOut: true }, 'timed out'], + [{ outputTruncated: true }, 'output exceeded'] + ])('refuses incomplete formatter output (%j)', async (result, message) => { + vi.mocked(runProcess).mockResolvedValueOnce({ + code: 0, + signal: null, + timedOut: false, + stdout: 'partial', + stderr: '', + ...result + }) + await expect(formatEditorText(store, target(), 'input')).rejects.toThrow(message) + }) +}) diff --git a/src/main/editor/format-editor-text.ts b/src/main/editor/format-editor-text.ts new file mode 100644 index 00000000000..958bb613aef --- /dev/null +++ b/src/main/editor/format-editor-text.ts @@ -0,0 +1,100 @@ +import { runProcess } from '@orca/process-host' +import { basename, dirname, join, matchesGlob, relative } from 'node:path' +import { lstat, realpath } from 'node:fs/promises' +import { + editorFormatterArgv, + editorFormatterSchema, + type EditorFormatResult +} from '../../shared/editor-formatter' +import { getRepoExecutionHostId } from '../../shared/execution-host' +import { isPathInsideOrEqual } from '../../shared/cross-platform-path' +import type { RuntimeStore } from '../runtime/runtime-store-contract' +import type { RuntimeFileExplorerPath } from '../runtime/files/file-target' +import { isENOENT } from '../ipc/filesystem/filesystem-path-containment' + +async function formatterFilePath(filePath: string): Promise { + try { + return await realpath(filePath) + } catch (error) { + if (!isENOENT(error)) { + throw error + } + const entry = await lstat(filePath).catch((reason: unknown) => { + if (!isENOENT(reason)) { + throw reason + } + return null + }) + if (entry?.isSymbolicLink()) { + throw new Error('Cannot format a dangling file link.') + } + return join(await realpath(dirname(filePath)), basename(filePath)) + } +} + +export async function formatEditorText( + store: Pick, + target: Pick & { + worktree: Pick + }, + text: string, + // Why: runs only once a formatter applies, so unconfigured or excluded + // files on hosts that cannot run formatters still save silently. + assertCanRunFormatter: () => void = () => {} +): Promise { + const repo = store + .getRepos() + .find( + (row) => + row.id === target.worktree.repoId && getRepoExecutionHostId(row) === target.executionHostId + ) + if (!repo?.editorFormatter?.command.trim()) { + return { kind: 'skipped' } + } + const config = editorFormatterSchema.parse(repo.editorFormatter) + const relativePath = relative(target.worktree.path, target.path).replace(/\\/g, '/') + if ( + !isPathInsideOrEqual(target.worktree.path, target.path) || + !config.include.some((glob) => matchesGlob(relativePath, glob)) + ) { + return { kind: 'skipped' } + } + assertCanRunFormatter() + const [root, filePath] = await Promise.all([ + realpath(target.worktree.path), + formatterFilePath(target.path) + ]) + if (!isPathInsideOrEqual(root, filePath)) { + throw new Error('Cannot format a file linked outside its workspace.') + } + const [program, ...args] = editorFormatterArgv( + config.command, + target.path, + relativePath, + process.platform + ) + const result = await runProcess({ + program, + args, + cwd: target.worktree.path, + input: text, + timeoutMs: 10_000, + maxOutputBytes: 512 * 1024, + killOnOutputLimit: true, + detached: process.platform !== 'win32', + terminationBarrier: true + }) + if (result.timedOut) { + throw new Error('Formatter timed out after 10 seconds.') + } + if (result.outputTruncated) { + throw new Error('Formatter output exceeded the 512 KB limit.') + } + if (result.code !== 0) { + throw new Error(result.stderr.trim().slice(0, 2000) || `Formatter exited with ${result.code}.`) + } + if (text.trim() && !result.stdout.trim()) { + throw new Error('Formatter returned no text for a non-empty buffer.') + } + return { kind: 'formatted', text: result.stdout } +} diff --git a/src/main/ipc/repos/repo-update-handler.ts b/src/main/ipc/repos/repo-update-handler.ts index 4dc9cb1378e..4ca6adfd350 100644 --- a/src/main/ipc/repos/repo-update-handler.ts +++ b/src/main/ipc/repos/repo-update-handler.ts @@ -40,6 +40,7 @@ export function registerRepoUpdateHandler(mainWindow: BrowserWindow, store: Stor | 'repoIcon' | 'upstream' | 'hookSettings' + | 'editorFormatter' | 'worktreeBaseRef' | 'worktreeBasePath' | 'kind' diff --git a/src/main/persistence/loading-store/repo-lifecycle-operations.ts b/src/main/persistence/loading-store/repo-lifecycle-operations.ts index 6811ff0fae4..5a16c013c1e 100644 --- a/src/main/persistence/loading-store/repo-lifecycle-operations.ts +++ b/src/main/persistence/loading-store/repo-lifecycle-operations.ts @@ -167,6 +167,7 @@ export class RepoLifecycleOperations { | 'gitRemoteIdentity' | 'defaultJiraProject' | 'hookSettings' + | 'editorFormatter' | 'worktreeBaseRef' | 'worktreeBasePath' | 'kind' diff --git a/src/main/persistence/tracking-repos/persistence-update-repo.test.ts b/src/main/persistence/tracking-repos/persistence-update-repo.test.ts index 77af426b070..74d90d1b34b 100644 --- a/src/main/persistence/tracking-repos/persistence-update-repo.test.ts +++ b/src/main/persistence/tracking-repos/persistence-update-repo.test.ts @@ -88,6 +88,25 @@ describe('Store', () => { expect(store.getRepo('r1')!.displayName).toBe('renamed') }) + it('persists opt-in formatting and the project command across reloads', async () => { + const store = createStore() + store.addRepo(makeRepo()) + expect(store.getSettings().editorFormatOnSave).toBe(false) + const editorFormatter = { + command: 'prettier --stdin-filepath "${file}"', + include: ['**/*.ts'] + } + store.updateSettings({ editorFormatOnSave: true }) + store.updateRepo({ id: 'r1' }, { editorFormatter }) + await store.flushPendingOrThrowAsync() + + const reloaded = createStore() + expect(reloaded.getSettings().editorFormatOnSave).toBe(true) + expect(reloaded.getRepo('r1')?.editorFormatter).toEqual(editorFormatter) + reloaded.updateRepo({ id: 'r1' }, { editorFormatter: { command: '', include: [] } }) + expect(reloaded.getRepo('r1')?.editorFormatter?.command).toBe('') + }) + it('invalidates local worktree scans across a git-folder-git update round trip', async () => { const store = await createStore() store.addRepo(makeRepo({ id: 'scan-round-trip', kind: 'git' })) diff --git a/src/main/persistence/tracking-repos/repo-sanitization.ts b/src/main/persistence/tracking-repos/repo-sanitization.ts index 541ef0e04a1..cb73ea26483 100644 --- a/src/main/persistence/tracking-repos/repo-sanitization.ts +++ b/src/main/persistence/tracking-repos/repo-sanitization.ts @@ -1,5 +1,6 @@ import type { RepoProjectHostSetupMethod } from '../../../shared/project-types' import type { Repo } from '../../../shared/repo-types' +import { editorFormatterSchema } from '../../../shared/editor-formatter' import type { GitRemoteIdentity } from '../../../shared/git-remote-identity' import { normalizeRepoBadgeColor } from '../../../shared/repo-badge-color' import { sanitizeRepoIcon } from '../../../shared/repo-icon' @@ -69,6 +70,7 @@ export function sanitizeRepoUpdatesForPersistence< Pick< Repo, | 'badgeColor' + | 'editorFormatter' | 'repoIcon' | 'upstream' | 'gitRemoteIdentity' @@ -84,6 +86,9 @@ export function sanitizeRepoUpdatesForPersistence< } >(updates: T): T { const sanitized = { ...updates } + if ('editorFormatter' in sanitized && sanitized.editorFormatter !== undefined) { + sanitized.editorFormatter = editorFormatterSchema.parse(sanitized.editorFormatter) + } if ('defaultJiraProject' in sanitized) { const reference = normalizeJiraProjectReference(sanitized.defaultJiraProject) if (reference === undefined) { diff --git a/src/main/persistence/tracking-repos/repo-update-operations.ts b/src/main/persistence/tracking-repos/repo-update-operations.ts index be5fd556a39..e00c3276587 100644 --- a/src/main/persistence/tracking-repos/repo-update-operations.ts +++ b/src/main/persistence/tracking-repos/repo-update-operations.ts @@ -94,6 +94,7 @@ export class RepoUpdatePersistenceOperations { | 'upstream' | 'gitRemoteIdentity' | 'hookSettings' + | 'editorFormatter' | 'worktreeBaseRef' | 'worktreeBasePath' | 'kind' diff --git a/src/main/runtime/files/file-mutations.ts b/src/main/runtime/files/file-mutations.ts index 433fd0bfaf2..64b19cf8a55 100644 --- a/src/main/runtime/files/file-mutations.ts +++ b/src/main/runtime/files/file-mutations.ts @@ -1,9 +1,11 @@ import { assertRuntimeFileMutationExpectation } from './file-mutation-expectation' +import { formatEditorText } from '../../editor/format-editor-text' import { requireLocalRuntimeFileRoute, requireRuntimeFileProvider, resolveFileExplorerPath, - resolveFileExplorerPaths + resolveFileExplorerPaths, + runtimeFileRouteForTarget } from './file-target' import type { RuntimeFileCommandHost } from './file-command-host' import { resolveRuntimeAuthorizedPath } from './chat-scratch-file-root' @@ -24,6 +26,28 @@ type RuntimeFileMutationHost = Pick< export class RuntimeFileMutations { constructor(private readonly host: RuntimeFileMutationHost) {} + async formatFileExplorerText( + worktreeSelector: string, + relativePath: string, + text: string, + expectedSshConnectionGeneration?: number, + expectedSshTargetId?: string, + expectedExecutionHostId?: string + ) { + const target = await resolveFileExplorerPath(this.host, worktreeSelector, relativePath) + assertRuntimeFileMutationExpectation( + target.executionHostId, + expectedExecutionHostId, + expectedSshTargetId, + expectedSshConnectionGeneration + ) + return formatEditorText(this.host.requireStore(), target, text, () => { + if (runtimeFileRouteForTarget(target).kind !== 'local') { + throw new Error('Format on Save does not support SSH-hosted files yet.') + } + }) + } + async writeFileExplorerFile( worktreeSelector: string, relativePath: string, diff --git a/src/main/runtime/orca-runtime-files.ts b/src/main/runtime/orca-runtime-files.ts index 5cf1825b4da..3493772ef0d 100644 --- a/src/main/runtime/orca-runtime-files.ts +++ b/src/main/runtime/orca-runtime-files.ts @@ -29,6 +29,7 @@ export class RuntimeFileCommands { readonly readDocPreviewChunk: RuntimeFileReads['readDocPreviewChunk'] readonly readFileExplorerChunk: RuntimeFileReads['readFileExplorerChunk'] readonly writeFileExplorerFile: RuntimeFileMutations['writeFileExplorerFile'] + readonly formatFileExplorerText: RuntimeFileMutations['formatFileExplorerText'] readonly writeFileExplorerFileBase64: RuntimeFileMutations['writeFileExplorerFileBase64'] readonly writeFileExplorerFileBase64Chunk: RuntimeFileMutations['writeFileExplorerFileBase64Chunk'] readonly createFileExplorerFile: RuntimeFileMutations['createFileExplorerFile'] @@ -68,6 +69,7 @@ export class RuntimeFileCommands { this.readDocPreviewChunk = this.reads.readDocPreviewChunk.bind(this.reads) this.readFileExplorerChunk = this.reads.readFileExplorerChunk.bind(this.reads) this.writeFileExplorerFile = this.mutations.writeFileExplorerFile.bind(this.mutations) + this.formatFileExplorerText = this.mutations.formatFileExplorerText.bind(this.mutations) this.writeFileExplorerFileBase64 = this.mutations.writeFileExplorerFileBase64.bind( this.mutations ) diff --git a/src/main/runtime/rpc/methods/files-mutation-methods.ts b/src/main/runtime/rpc/methods/files-mutation-methods.ts index d74a25c4c52..770fceb0c9c 100644 --- a/src/main/runtime/rpc/methods/files-mutation-methods.ts +++ b/src/main/runtime/rpc/methods/files-mutation-methods.ts @@ -8,6 +8,7 @@ import { FileMutationOpen, FileRename, FileWrite, + FileFormatText, FileWriteIfUnchanged, FileWriteBase64, FileWriteBase64Chunk @@ -37,6 +38,18 @@ function sshMutationArguments( } export const FILE_MUTATION_METHODS = [ + defineMethod({ + name: 'files.formatText', + permission: 'workspace', + params: FileFormatText, + handler: async (params, { runtime }) => + runtime.formatFileExplorerText( + params.worktree, + params.relativePath, + params.content, + ...sshMutationArguments(params) + ) + }), defineMethod({ name: 'files.write', permission: 'workspace', diff --git a/src/main/runtime/runtime-file-command-surface.ts b/src/main/runtime/runtime-file-command-surface.ts index 98e6493e60a..5e80f49f9a4 100644 --- a/src/main/runtime/runtime-file-command-surface.ts +++ b/src/main/runtime/runtime-file-command-surface.ts @@ -20,6 +20,7 @@ type RuntimeFileCommandName = | 'readDocPreviewFile' | 'readDocPreviewChunk' | 'writeFileExplorerFile' + | 'formatFileExplorerText' | 'writeFileExplorerFileBase64' | 'writeFileExplorerFileBase64Chunk' | 'createFileExplorerFile' @@ -63,6 +64,7 @@ export function installRuntimeFileCommandSurface( readDocPreviewFile: commands.readDocPreviewFile.bind(commands), readDocPreviewChunk: commands.readDocPreviewChunk.bind(commands), writeFileExplorerFile: commands.writeFileExplorerFile.bind(commands), + formatFileExplorerText: commands.formatFileExplorerText.bind(commands), writeFileExplorerFileBase64: commands.writeFileExplorerFileBase64.bind(commands), writeFileExplorerFileBase64Chunk: commands.writeFileExplorerFileBase64Chunk.bind(commands), createFileExplorerFile: commands.createFileExplorerFile.bind(commands), diff --git a/src/main/runtime/runtime-repository-settings-controller.ts b/src/main/runtime/runtime-repository-settings-controller.ts index 30d4cfa61c7..8fe651112d7 100644 --- a/src/main/runtime/runtime-repository-settings-controller.ts +++ b/src/main/runtime/runtime-repository-settings-controller.ts @@ -34,6 +34,7 @@ type RepositoryUpdates = Partial< | 'repoIcon' | 'upstream' | 'hookSettings' + | 'editorFormatter' | 'worktreeBaseRef' | 'worktreeBasePath' | 'kind' diff --git a/src/preload/api/repository-api.ts b/src/preload/api/repository-api.ts index 6d5b8657439..c71704e4529 100644 --- a/src/preload/api/repository-api.ts +++ b/src/preload/api/repository-api.ts @@ -52,6 +52,7 @@ export type RepositoryApi = { | 'repoIcon' | 'upstream' | 'hookSettings' + | 'editorFormatter' | 'worktreeBaseRef' | 'worktreeBasePath' | 'kind' diff --git a/src/renderer/src/components/editor/EditorContent.monaco-lifecycle.test.tsx b/src/renderer/src/components/editor/EditorContent.monaco-lifecycle.test.tsx index 8be800d1ddf..2e34f63a6c9 100644 --- a/src/renderer/src/components/editor/EditorContent.monaco-lifecycle.test.tsx +++ b/src/renderer/src/components/editor/EditorContent.monaco-lifecycle.test.tsx @@ -100,8 +100,11 @@ vi.mock('@/lib/lazy-with-retry', async () => { } // Use the real mount reconciler to observe key ordering with retained-model behavior intact. syncContentOnMount( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: content sync calls only the editor methods stubbed here. { getModel: () => model, + saveViewState: () => null, + restoreViewState: () => {}, pushUndoStop: () => { retained.undo.push('unexpected undo stop') return true diff --git a/src/renderer/src/components/editor/editor-format-on-save.test.ts b/src/renderer/src/components/editor/editor-format-on-save.test.ts new file mode 100644 index 00000000000..b16ecb60c71 --- /dev/null +++ b/src/renderer/src/components/editor/editor-format-on-save.test.ts @@ -0,0 +1,199 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { toast } from 'sonner' +import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' +import { makeWorktree } from '@/store/slices/worktrees-slice-test-fixtures' +import { createEditorSaveQueue, type EditorSaveQueue } from './editor-save-queue' +import { + createEditorStore, + createFakeEditorDisk, + stubEditorWindowWithDisk, + withLoadedBaselines +} from './editor-autosave-controller-test-fixture' +import { __clearSelfWriteRegistryForTests } from './editor-self-write-registry' +import { registerPendingEditorFlush } from './editor-pending-flush' +import { usePluginPanelsStore } from '@/store/plugin-panels' +import { editorFormatOnSave } from './editor-format-on-save' +import { bumpEditorDiscardEpoch, EditorSaveDiscardedError } from './editor-discard-epoch' +import { editorBufferIdOfView } from '@/store/slices/editor/buffers/editor-buffer-views' + +vi.mock('@/lib/connection-context', () => ({ getConnectionIdForFile: () => undefined })) +vi.mock('@/runtime/runtime-rpc-client', () => ({ callRuntimeRpc: vi.fn() })) +vi.mock('sonner', () => ({ toast: { error: vi.fn() } })) + +describe('format on the guarded save path', () => { + let disk: ReturnType + let store: ReturnType + let queue: EditorSaveQueue + const file = () => store.getState().openFiles[0] + const edit = (text: string): void => { + store.getState().setEditorDraft('a', text) + store.getState().markFileDirty('a', true) + } + + beforeEach(() => { + vi.clearAllMocks() + disk = stubEditorWindowWithDisk(createFakeEditorDisk({ '/repo/a.ts': '' })) + store = createEditorStore() + store.setState({ + worktreesByRepo: { + 'repo-1': [makeWorktree({ id: 'wt-1', repoId: 'repo-1', path: '/repo', hostId: 'local' })] + }, + settings: { ...store.getState().settings!, editorFormatOnSave: true }, + openFiles: [ + { + id: 'a', + filePath: '/repo/a.ts', + relativePath: 'a.ts', + worktreeId: 'wt-1', + language: 'typescript', + mode: 'edit', + isDirty: false + } + ] + }) + withLoadedBaselines(store, disk) + usePluginPanelsStore.setState({ plugins: [] }) + vi.mocked(callRuntimeRpc).mockResolvedValue({ kind: 'formatted', text: 'const x = 1\n' }) + queue = createEditorSaveQueue(store) + }) + + afterEach(() => { + queue.dispose() + __clearSelfWriteRegistryForTests() + vi.unstubAllGlobals() + }) + + it.each(['user', 'autosave'] as const)( + 'formats before a %s save and leaves the buffer clean', + async (trigger) => { + edit('const x=1') + expect(await queue.queueSave(file(), 'const x=1', trigger)).toBe('written') + expect(disk.files.get('/repo/a.ts')).toBe('const x = 1\n') + expect(file().isDirty).toBe(false) + expect(callRuntimeRpc).toHaveBeenCalledWith( + { kind: 'local' }, + 'files.formatText', + expect.objectContaining({ + worktree: 'id:wt-1', + relativePath: 'a.ts', + content: 'const x=1', + expectedExecutionHostId: 'local' + }), + expect.any(Object) + ) + } + ) + + it('does not run when disabled or during restart', async () => { + store.setState({ settings: { ...store.getState().settings!, editorFormatOnSave: false } }) + edit('disabled') + await queue.queueSave(file(), 'disabled') + store.setState({ settings: { ...store.getState().settings!, editorFormatOnSave: true } }) + edit('restart') + await queue.queueSave(file(), 'restart', 'restart') + expect(callRuntimeRpc).not.toHaveBeenCalled() + expect(disk.files.get('/repo/a.ts')).toBe('restart') + }) + + it('saves original text and surfaces the formatter error without marking a failed save', async () => { + vi.mocked(callRuntimeRpc).mockRejectedValueOnce(new Error('syntax error')) + edit('invalid syntax') + expect(await queue.queueSave(file(), 'invalid syntax')).toBe('written') + expect(disk.files.get('/repo/a.ts')).toBe('invalid syntax') + expect(file().isDirty).toBe(false) + expect(toast.error).toHaveBeenCalledWith( + expect.stringContaining('syntax error'), + expect.any(Object) + ) + }) + + it('keeps edits made during formatting dirty while saving only the formatted snapshot', async () => { + let finish!: (value: { kind: 'formatted'; text: string }) => void + vi.mocked(callRuntimeRpc).mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve + }) + ) + edit('one') + const saving = queue.queueSave(file(), 'one') + await vi.waitFor(() => expect(callRuntimeRpc).toHaveBeenCalledOnce()) + edit('one two') + finish({ kind: 'formatted', text: 'ONE' }) + expect(await saving).toBe('written') + expect(disk.files.get('/repo/a.ts')).toBe('ONE') + expect(store.getState().editorDrafts.a).toBe('one two') + expect(file().isDirty).toBe(true) + }) + + it('flushes view-held typing before adopting formatter output', async () => { + let unregister = (): void => {} + vi.mocked(callRuntimeRpc).mockImplementationOnce(async () => { + unregister = registerPendingEditorFlush('a', () => edit('typed meanwhile')) + return { kind: 'formatted', text: 'ONE' } + }) + edit('one') + await queue.queueSave(file(), 'one') + unregister() + expect(store.getState().editorDrafts.a).toBe('typed meanwhile') + expect(file().isDirty).toBe(true) + }) + + it('sends remote formatting to the file owner with the captured pairing, not the focused host', async () => { + edit('remote text') + const assertCurrent = vi.fn() + await editorFormatOnSave( + store, + file(), + { + target: { kind: 'environment', environmentId: 'owner-host' }, + worktreeId: 'wt-1', + worktreePath: '/repo', + expectedExecutionHostId: 'local', + expectedEnvironmentPairingRevision: 7 + }, + 'user', + 'remote text', + assertCurrent + ) + expect(callRuntimeRpc).toHaveBeenCalledWith( + { kind: 'environment', environmentId: 'owner-host' }, + 'files.formatText', + expect.objectContaining({ relativePath: 'a.ts', content: 'remote text' }), + expect.objectContaining({ + expectedEnvironmentPairingRevision: 7, + beforeSend: assertCurrent + }) + ) + }) + + it('does not adopt or write a formatter result after the edits were discarded', async () => { + let finish!: (value: { kind: 'formatted'; text: string }) => void + vi.mocked(callRuntimeRpc).mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve + }) + ) + edit('one') + const saving = queue.queueSave(file(), 'one') + const rejection = expect(saving).rejects.toBeInstanceOf(EditorSaveDiscardedError) + await vi.waitFor(() => expect(callRuntimeRpc).toHaveBeenCalledOnce()) + bumpEditorDiscardEpoch(editorBufferIdOfView(store.getState(), file().id)) + finish({ kind: 'formatted', text: 'ONE' }) + await rejection + expect(disk.files.get('/repo/a.ts')).toBe('') + expect(store.getState().editorDrafts.a).toBe('one') + }) + it('still refuses a disk conflict that arrives while the formatter is running', async () => { + vi.mocked(callRuntimeRpc).mockImplementationOnce(async () => { + disk.files.set('/repo/a.ts', 'external write') + return { kind: 'formatted', text: 'ONE' } + }) + edit('one') + expect(await queue.queueSave(file(), 'one')).toBe('blocked-stale') + expect(disk.files.get('/repo/a.ts')).toBe('external write') + expect(file().externalMutation).toBe('changed') + expect(file().isDirty).toBe(true) + }) +}) diff --git a/src/renderer/src/components/editor/editor-format-on-save.ts b/src/renderer/src/components/editor/editor-format-on-save.ts new file mode 100644 index 00000000000..71be5401062 --- /dev/null +++ b/src/renderer/src/components/editor/editor-format-on-save.ts @@ -0,0 +1,81 @@ +import { toast } from 'sonner' +import { translate } from '@/i18n/i18n' +import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' +import { + getRelativePathInsideWorktree, + withSshMutationExpectation +} from '@/runtime/runtime-file-routing' +import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector' +import { assertRemoteWriteFitsOneRequest } from '@/runtime/runtime-file-mutation-client' +import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client-types' +import type { OpenFile } from '@/store/slices/editor' +import { editorBufferIdOfView } from '@/store/slices/editor/buffers/editor-buffer-views' +import type { EditorFormatResult } from '../../../../shared/editor-formatter' +import type { AppStoreApi } from './editor-save-queue' +import type { EditorSaveTrigger } from './editor-save-disk-precondition' +import { hasEditorBytesDraft } from './editor-bytes-documents' +import { adoptEditorSaveText } from './editor-save-text-transform' + +export function editorFormatOnSave( + store: AppStoreApi, + file: OpenFile, + context: RuntimeFileOperationArgs, + trigger: EditorSaveTrigger, + text: string, + assertCurrent: () => void +): Promise | null { + const worktreeId = context.worktreeId + if ( + store.getState().settings?.editorFormatOnSave !== true || + trigger === 'restart' || + hasEditorBytesDraft(file.id) || + !worktreeId + ) { + return null + } + const relativePath = getRelativePathInsideWorktree(context.worktreePath, file.filePath) + if (relativePath === null) { + return null + } + const recordId = editorBufferIdOfView(store.getState(), file.id) + return (async () => { + let result: EditorFormatResult + try { + if (context.target.kind === 'environment') { + assertRemoteWriteFitsOneRequest(text) + } + result = await callRuntimeRpc( + context.target, + 'files.formatText', + withSshMutationExpectation(context, { + worktree: toRuntimeWorktreeSelector(worktreeId), + relativePath, + content: text + }), + { + timeoutMs: 15_000, + expectedEnvironmentPairingRevision: context.expectedEnvironmentPairingRevision, + beforeSend: assertCurrent + } + ) + } catch (error) { + assertCurrent() + toast.error( + translate( + 'components.editor.formatOnSave.failed', + 'Could not format {{file}}. Saving without formatting: {{reason}}', + { + file: file.relativePath, + reason: error instanceof Error ? error.message : String(error) + } + ), + { id: `format-on-save:${file.id}` } + ) + return text + } + assertCurrent() + return result.kind === 'formatted' + ? adoptEditorSaveText(store, recordId, text, result.text, assertCurrent) + : text + })() +} diff --git a/src/renderer/src/components/editor/editor-save-plugin-hook.model.test.tsx b/src/renderer/src/components/editor/editor-save-plugin-hook.model.test.tsx index 990f5b06a88..a917de7c71e 100644 --- a/src/renderer/src/components/editor/editor-save-plugin-hook.model.test.tsx +++ b/src/renderer/src/components/editor/editor-save-plugin-hook.model.test.tsx @@ -33,6 +33,8 @@ function fakeEditor(initial: string) { const instance = { getModel: () => model, getValue: () => value, + saveViewState: () => null, + restoreViewState: () => {}, pushUndoStop: () => true, onDidChangeModelContent: (listener: () => void) => { listeners.add(listener) diff --git a/src/renderer/src/components/editor/editor-save-plugin-hook.ts b/src/renderer/src/components/editor/editor-save-plugin-hook.ts index cdd8c9da59c..e53edf838b5 100644 --- a/src/renderer/src/components/editor/editor-save-plugin-hook.ts +++ b/src/renderer/src/components/editor/editor-save-plugin-hook.ts @@ -4,11 +4,8 @@ import type { AppStoreApi } from './editor-save-queue' import type { OpenFile } from '@/store/slices/editor' import type { EditorSaveTrigger } from './editor-save-disk-precondition' import { hasEditorBytesDraft } from './editor-bytes-documents' -import { flushEditorViewsForSave } from './editor-pending-flush' -import { - editorBufferIdOfView, - editorBufferViewIds -} from '@/store/slices/editor/buffers/editor-buffer-views' +import { editorBufferIdOfView } from '@/store/slices/editor/buffers/editor-buffer-views' +import { adoptEditorSaveText } from './editor-save-text-transform' import type { PluginHostListEntry } from '../../../../preload/api-types' const SAVE_HOOK = 'editor.save.before' @@ -74,21 +71,7 @@ export async function applyEditorSavePluginHook( if (decision.kind === 'deny') { throw new EditorSaveDeniedByPluginError(decision.pluginKey, decision.reason) } - const decided = decision.text ?? text - if (decided === text) { - return decided - } - const open = new Set(store.getState().openFiles.map((tab) => tab.id)) - const survivor = editorBufferViewIds(store.getState(), recordId).find((id) => open.has(id)) - if (survivor === undefined) { - return decided - } - // Typing still held by a view (rich Markdown serializes on a timer) must count as a newer edit. - await flushEditorViewsForSave(store.getState, survivor) - if (store.getState().editorDrafts[survivor] === text) { - store.getState().setEditorDraft(survivor, decided) - } - return decided + return adoptEditorSaveText(store, recordId, text, decision.text ?? text) } /** The hook for a save the person asked for, or null (no plugin listens, or an autosave or diff --git a/src/renderer/src/components/editor/editor-save-queue.ts b/src/renderer/src/components/editor/editor-save-queue.ts index 961ab11d108..2589c0234dd 100644 --- a/src/renderer/src/components/editor/editor-save-queue.ts +++ b/src/renderer/src/components/editor/editor-save-queue.ts @@ -33,7 +33,7 @@ import { type EditorSaveResult, type EditorSaveTrigger } from './editor-save-disk-precondition' -import { editorSavePluginHook } from './editor-save-plugin-hook' +import { editorSaveTransform } from './editor-save-transform-pipeline' import { withEditorFileWriteLock } from './editor-file-write-lock' import { liveEditorSaveWriter, @@ -132,8 +132,8 @@ export function createEditorSaveQueue(store: AppStoreApi): EditorSaveQueue { return nothingToWrite } const buffered = store.getState().editorDrafts[fileId] ?? request.fallbackContent - const hooked = editorSavePluginHook(store, liveFile, trigger, buffered) - const contentToSave = hooked ? await hooked : buffered + const transformed = editorSaveTransform(store, liveFile, request, buffered) + const contentToSave = transformed ? await transformed : buffered if ( trigger === 'autosave' && hasPendingEditorBufferChange(store.getState(), fileId) && diff --git a/src/renderer/src/components/editor/editor-save-text-transform.ts b/src/renderer/src/components/editor/editor-save-text-transform.ts new file mode 100644 index 00000000000..0c7ad20b04f --- /dev/null +++ b/src/renderer/src/components/editor/editor-save-text-transform.ts @@ -0,0 +1,26 @@ +import type { AppStoreApi } from './editor-save-queue' +import { editorBufferViewIds } from '@/store/slices/editor/buffers/editor-buffer-views' +import { flushEditorViewsForSave } from './editor-pending-flush' + +export async function adoptEditorSaveText( + store: AppStoreApi, + recordId: string, + before: string, + after: string, + beforeAdopt?: () => void +): Promise { + if (after === before) { + return after + } + const open = new Set(store.getState().openFiles.map((tab) => tab.id)) + const survivor = editorBufferViewIds(store.getState(), recordId).find((id) => open.has(id)) + if (survivor !== undefined) { + // Flush view-held typing before deciding whether the formatter's input is still current. + await flushEditorViewsForSave(store.getState, survivor) + beforeAdopt?.() + if (store.getState().editorDrafts[survivor] === before) { + store.getState().setEditorDraft(survivor, after) + } + } + return after +} diff --git a/src/renderer/src/components/editor/editor-save-transform-pipeline.ts b/src/renderer/src/components/editor/editor-save-transform-pipeline.ts new file mode 100644 index 00000000000..6a8a5cf4377 --- /dev/null +++ b/src/renderer/src/components/editor/editor-save-transform-pipeline.ts @@ -0,0 +1,33 @@ +import type { OpenFile } from '@/store/slices/editor' +import type { AppStoreApi } from './editor-save-queue' +import type { EditorSaveRequest } from './editor-save-slots' +import { EditorSaveDiscardedError, editorDiscardEpoch } from './editor-discard-epoch' +import { editorFormatOnSave } from './editor-format-on-save' +import { editorSavePluginHook } from './editor-save-plugin-hook' + +export function editorSaveTransform( + store: AppStoreApi, + file: OpenFile, + request: EditorSaveRequest, + text: string +): Promise | null { + if (!('operation' in request.invocation)) { + throw request.invocation.error + } + const { operation } = request.invocation + const formatted = editorFormatOnSave( + store, + file, + operation.mutationContext(), + request.trigger, + text, + () => { + operation.assertAuthorityCurrent() + if (editorDiscardEpoch(request.recordId) !== request.discardEpoch) { + throw new EditorSaveDiscardedError() + } + } + ) + const hook = (content: string) => editorSavePluginHook(store, file, request.trigger, content) + return formatted ? formatted.then((content) => hook(content) ?? content) : hook(text) +} diff --git a/src/renderer/src/components/editor/editor-shared-record-clean-view.test.ts b/src/renderer/src/components/editor/editor-shared-record-clean-view.test.ts index 6e6f1f25164..61149f11583 100644 --- a/src/renderer/src/components/editor/editor-shared-record-clean-view.test.ts +++ b/src/renderer/src/components/editor/editor-shared-record-clean-view.test.ts @@ -87,6 +87,8 @@ function openSharedMarkdown() { getValue: () => model.getValue(), onDidChangeModelContent: (listener: (event: editor.IModelContentChangedEvent) => void) => model.onDidChangeContent(listener), + saveViewState: () => null, + restoreViewState: () => {}, pushUndoStop: () => true } as unknown as editor.IStandaloneCodeEditor const contentRef = { current: viewText(a.id) } diff --git a/src/renderer/src/components/editor/monaco-content-sync.bench.ts b/src/renderer/src/components/editor/monaco-content-sync.bench.ts index 8415061cde9..085232d0918 100644 --- a/src/renderer/src/components/editor/monaco-content-sync.bench.ts +++ b/src/renderer/src/components/editor/monaco-content-sync.bench.ts @@ -29,8 +29,11 @@ async function measureReconciliation( const replacementSamples: number[] = [] const measure = (next: string): number => { const model = monaco.editor.createModel(initial, 'plaintext') + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: content sync calls only the editor methods stubbed here. const editorInstance = { getModel: () => model, + saveViewState: () => null, + restoreViewState: () => {}, pushUndoStop: () => true } as unknown as monaco.editor.IStandaloneCodeEditor const startedAt = performance.now() diff --git a/src/renderer/src/components/editor/monaco-content-sync.test.ts b/src/renderer/src/components/editor/monaco-content-sync.test.ts index 96996207963..cf2b17b07ee 100644 --- a/src/renderer/src/components/editor/monaco-content-sync.test.ts +++ b/src/renderer/src/components/editor/monaco-content-sync.test.ts @@ -30,9 +30,12 @@ function createHarness( applyEdits } as unknown as editor.ITextModel const pushUndoStop = vi.fn() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the editor double implements every method exercised by content sync. const editorInstance = { getModel: () => model, - pushUndoStop + pushUndoStop, + saveViewState: vi.fn(() => null), + restoreViewState: vi.fn() } as unknown as editor.IStandaloneCodeEditor return { editorInstance, @@ -45,6 +48,25 @@ function createHarness( } describe('monaco content sync', () => { + it('preserves the cursor, selection and viewport when formatting replaces the buffer', () => { + const harness = createHarness('const a={x:1}') + const viewState: editor.ICodeEditorViewState = { + cursorState: [], + viewState: { + scrollLeft: 0, + firstPosition: { lineNumber: 1, column: 1 }, + firstPositionDeltaTop: 0 + }, + contributionsState: {} + } + vi.mocked(harness.editorInstance.saveViewState).mockReturnValue(viewState) + + syncContentUpdate(harness.editorInstance, 'const a = { x: 1 }\n') + + expect(harness.editorInstance.restoreViewState).toHaveBeenCalledWith(viewState) + expect(harness.pushUndoStop).toHaveBeenCalledTimes(2) + }) + it('inserts only an append-only suffix at the model end', () => { const harness = createHarness('first\nsecond\nlast') diff --git a/src/renderer/src/components/editor/monaco-content-sync.ts b/src/renderer/src/components/editor/monaco-content-sync.ts index 2c6f0a74b08..f133601f985 100644 --- a/src/renderer/src/components/editor/monaco-content-sync.ts +++ b/src/renderer/src/components/editor/monaco-content-sync.ts @@ -49,7 +49,11 @@ function replaceModelContent( return } const fullRange = model.getFullModelRange() + const viewState = mode === 'undoable' ? editorInstance.saveViewState() : null applyModelEdit(editorInstance, model, { range: fullRange, text: content }, mode, withUndoStops) + if (viewState) { + editorInstance.restoreViewState(viewState) + } } /** Reconcile retained models on remount: the React wrapper skips its initial value sync, hiding intervening external writes. */ diff --git a/src/renderer/src/components/editor/monaco-content-sync.undo-history.test.ts b/src/renderer/src/components/editor/monaco-content-sync.undo-history.test.ts index 0f8a4069d36..4c42f112643 100644 --- a/src/renderer/src/components/editor/monaco-content-sync.undo-history.test.ts +++ b/src/renderer/src/components/editor/monaco-content-sync.undo-history.test.ts @@ -13,8 +13,10 @@ function createEditor(initialContent: string): { models.push(model) return { model, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this editor double delegates model edits and undo to a real Monaco model. editorInstance: { getModel: () => model, + saveViewState: () => null, pushUndoStop: () => { model.pushStackElement() return true diff --git a/src/renderer/src/components/editor/monaco-content-sync.undo-retention.bench.ts b/src/renderer/src/components/editor/monaco-content-sync.undo-retention.bench.ts index caf46d9d872..01d2e943524 100644 --- a/src/renderer/src/components/editor/monaco-content-sync.undo-retention.bench.ts +++ b/src/renderer/src/components/editor/monaco-content-sync.undo-retention.bench.ts @@ -38,8 +38,11 @@ async function measureUndoRetention(mode: MonacoContentSyncMode): Promise<{ await forceGcAndSettle() const beforeArrayBuffers = process.memoryUsage().arrayBuffers const model = monaco.editor.createModel('', 'plaintext') + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: content sync calls only the editor methods stubbed here. const editorInstance = { getModel: () => model, + saveViewState: () => null, + restoreViewState: () => {}, pushUndoStop: () => { model.pushStackElement() return true diff --git a/src/renderer/src/components/editor/use-monaco-content-sync-bridge.events.test.tsx b/src/renderer/src/components/editor/use-monaco-content-sync-bridge.events.test.tsx index 2afffe3c3d0..be4f9113d3f 100644 --- a/src/renderer/src/components/editor/use-monaco-content-sync-bridge.events.test.tsx +++ b/src/renderer/src/components/editor/use-monaco-content-sync-bridge.events.test.tsx @@ -26,6 +26,8 @@ function mountBridge(model: editor.ITextModel) { getValue: () => model.getValue(), onDidChangeModelContent: (listener: (event: editor.IModelContentChangedEvent) => void) => model.onDidChangeContent(listener), + saveViewState: () => null, + restoreViewState: () => {}, pushUndoStop: () => { model.pushStackElement() return true diff --git a/src/renderer/src/components/settings/EditorFormatOnSaveSetting.tsx b/src/renderer/src/components/settings/EditorFormatOnSaveSetting.tsx new file mode 100644 index 00000000000..dd41ce17002 --- /dev/null +++ b/src/renderer/src/components/settings/EditorFormatOnSaveSetting.tsx @@ -0,0 +1,40 @@ +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import { translate } from '@/i18n/i18n' +import { SearchableSetting } from './SearchableSetting' +import { SettingsSwitchRow } from './SettingsFormControls' + +export function getFormatOnSaveSearchEntry() { + return { + title: translate('settings.editorFormatOnSave.title', 'Format on Save'), + description: translate( + 'settings.editorFormatOnSave.description', + 'Format text before manual saves and auto save. Configure a formatter command in Settings > Projects. Off by default.' + ), + keywords: [ + translate( + 'settings.editorFormatOnSave.keywords', + 'formatter format on save prettier biome gofmt' + ) + ] + } +} + +export function EditorFormatOnSaveSetting({ + settings, + updateSettings +}: { + settings: GlobalSettings + updateSettings: (updates: Partial) => void +}): React.JSX.Element { + const entry = getFormatOnSaveSearchEntry() + return ( + + updateSettings({ editorFormatOnSave: !settings.editorFormatOnSave })} + /> + + ) +} diff --git a/src/renderer/src/components/settings/GeneralEditorSettingsSection.tsx b/src/renderer/src/components/settings/GeneralEditorSettingsSection.tsx index 3ae6b73305b..dd9b69028fd 100644 --- a/src/renderer/src/components/settings/GeneralEditorSettingsSection.tsx +++ b/src/renderer/src/components/settings/GeneralEditorSettingsSection.tsx @@ -21,6 +21,7 @@ import { RichMarkdownSpellcheckSetting } from './RichMarkdownSpellcheckSetting' import { DiffShowWhitespaceSetting } from './DiffShowWhitespaceSetting' import { EditorWordWrapSetting } from './EditorWordWrapSetting' import { EditorFontFamilySetting } from './EditorFontFamilySetting' +import { EditorFormatOnSaveSetting } from './EditorFormatOnSaveSetting' import { EditorTypographySettings } from './EditorTypographySettings' import { createAutoSaveDelayDraftState, @@ -98,6 +99,8 @@ export function GeneralEditorSettingsSection({ )} /> + + [ + getFormatOnSaveSearchEntry(), { title: translate('auto.components.settings.general.search.ae21e806ce', 'Auto Save Files'), description: translate( diff --git a/src/renderer/src/components/settings/repository/RepositoryFormatterSection.tsx b/src/renderer/src/components/settings/repository/RepositoryFormatterSection.tsx new file mode 100644 index 00000000000..bfbcc251f3d --- /dev/null +++ b/src/renderer/src/components/settings/repository/RepositoryFormatterSection.tsx @@ -0,0 +1,151 @@ +import { useId, useState } from 'react' +import type { Repo } from '../../../../../shared/repo-types' +import { + DEFAULT_FORMATTER_INCLUDE, + editorFormatterSchema +} from '../../../../../shared/editor-formatter' +import { translate } from '@/i18n/i18n' +import { Button } from '../../ui/button' +import { Input } from '../../ui/input' +import { Label } from '../../ui/label' +import { Textarea } from '../../ui/textarea' +import { SearchableSetting } from '../SearchableSetting' +import { SettingsSubsectionHeader } from '../SettingsFormControls' + +export function getRepositoryFormatterSearchEntry() { + return { + title: translate('settings.repositoryFormatter.title', 'Formatter Command'), + description: translate( + 'settings.repositoryFormatter.description', + 'Use this project’s formatter for Format on Save.' + ), + keywords: [ + translate( + 'settings.editorFormatOnSave.keywords', + 'formatter format on save prettier biome gofmt' + ) + ] + } +} + +export function RepositoryFormatterSection({ + repo, + updateRepo, + forceVisible = false +}: { + repo: Repo + forceVisible?: boolean + updateRepo: (repoId: string, updates: Pick) => void | Promise +}): React.JSX.Element { + const commandId = useId() + const includeId = useId() + const sourceKey = JSON.stringify(repo.editorFormatter) + const [source, setSource] = useState(sourceKey) + const [command, setCommand] = useState(repo.editorFormatter?.command ?? '') + const [include, setInclude] = useState( + repo.editorFormatter?.include.join('\n') ?? DEFAULT_FORMATTER_INCLUDE + ) + const [saving, setSaving] = useState(false) + const [error, setError] = useState('') + if (source !== sourceKey) { + setSource(sourceKey) + setCommand(repo.editorFormatter?.command ?? '') + setInclude(repo.editorFormatter?.include.join('\n') ?? DEFAULT_FORMATTER_INCLUDE) + } + const entry = getRepositoryFormatterSearchEntry() + const changed = + command !== (repo.editorFormatter?.command ?? '') || + include !== (repo.editorFormatter?.include.join('\n') ?? DEFAULT_FORMATTER_INCLUDE) + + const save = async (): Promise => { + setSaving(true) + setError('') + try { + const editorFormatter = editorFormatterSchema.parse({ + command: command.trim(), + include: include + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + }) + const saved = await updateRepo(repo.id, { editorFormatter }) + if (saved === false) { + setError( + translate('settings.repositoryFormatter.saveFailed', 'Could not save formatter settings.') + ) + } + } catch (reason) { + setError(reason instanceof Error ? reason.message : String(reason)) + } finally { + setSaving(false) + } + } + + return ( + +
+ +
{ + event.preventDefault() + void save() + }} + > +
+ + setCommand(event.target.value)} + placeholder={translate( + 'settings.repositoryFormatter.placeholder', + 'npx --no-install prettier --stdin-filepath "${file}"' + )} + variant="code" + disabled={saving} + aria-invalid={Boolean(error)} + /> +

+ {translate( + 'settings.repositoryFormatter.commandHelp', + 'Runs on the file’s host in the workspace root. Reads the buffer from stdin and must print only formatted text to stdout. Use ${file} or ${relativeFile} for the path. Arguments are passed directly, without a shell. Do not use --write. Leave empty to disable for this project.' + )} +

+
+
+ +