feat(editor): add opt-in format on save (#37716)

* feat(editor): add opt-in format on save

Run project-configured stdin/stdout formatters before guarded saves, with an off-by-default setting, host-aware execution, and safe error fallback.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(settings): localize formatter search terms and placeholder

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* test(editor): stub view state in Monaco fakes

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(editor): skip SSH formatter guard when no formatter applies

Unconfigured or excluded files in SSH workspaces now save silently instead
of toasting a misleading reconnect error. Configured SSH formatters report
that SSH hosts are not supported yet. Document that include globs skip
dot-prefixed names unless listed explicitly.

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Neil
2026-11-03 01:29:31 -08:00
committed by GitHub
co-authored by Copilot
parent 957a82359e
commit 2f6601da60
44 changed files with 1019 additions and 25 deletions
+142
View File
@@ -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<typeof ProcessHost>()
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)
})
})
+100
View File
@@ -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<string> {
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<RuntimeStore, 'getRepos'>,
target: Pick<RuntimeFileExplorerPath, 'path' | 'executionHostId'> & {
worktree: Pick<RuntimeFileExplorerPath['worktree'], 'path' | 'repoId'>
},
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<EditorFormatResult> {
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 }
}
@@ -40,6 +40,7 @@ export function registerRepoUpdateHandler(mainWindow: BrowserWindow, store: Stor
| 'repoIcon'
| 'upstream'
| 'hookSettings'
| 'editorFormatter'
| 'worktreeBaseRef'
| 'worktreeBasePath'
| 'kind'
@@ -167,6 +167,7 @@ export class RepoLifecycleOperations {
| 'gitRemoteIdentity'
| 'defaultJiraProject'
| 'hookSettings'
| 'editorFormatter'
| 'worktreeBaseRef'
| 'worktreeBasePath'
| 'kind'
@@ -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' }))
@@ -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) {
@@ -94,6 +94,7 @@ export class RepoUpdatePersistenceOperations {
| 'upstream'
| 'gitRemoteIdentity'
| 'hookSettings'
| 'editorFormatter'
| 'worktreeBaseRef'
| 'worktreeBasePath'
| 'kind'
+25 -1
View File
@@ -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,
+2
View File
@@ -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
)
@@ -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',
@@ -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),
@@ -34,6 +34,7 @@ type RepositoryUpdates = Partial<
| 'repoIcon'
| 'upstream'
| 'hookSettings'
| 'editorFormatter'
| 'worktreeBaseRef'
| 'worktreeBasePath'
| 'kind'
+1
View File
@@ -52,6 +52,7 @@ export type RepositoryApi = {
| 'repoIcon'
| 'upstream'
| 'hookSettings'
| 'editorFormatter'
| 'worktreeBaseRef'
| 'worktreeBasePath'
| 'kind'
@@ -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
@@ -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<typeof createFakeEditorDisk>
let store: ReturnType<typeof createEditorStore>
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)
})
})
@@ -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<string> | 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<EditorFormatResult>(
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
})()
}
@@ -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)
@@ -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
@@ -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) &&
@@ -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<string> {
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
}
@@ -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<string> | 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)
}
@@ -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) }
@@ -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()
@@ -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')
@@ -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. */
@@ -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
@@ -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
@@ -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
@@ -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<GlobalSettings>) => void
}): React.JSX.Element {
const entry = getFormatOnSaveSearchEntry()
return (
<SearchableSetting {...entry}>
<SettingsSwitchRow
label={entry.title}
description={entry.description}
checked={settings.editorFormatOnSave === true}
onChange={() => updateSettings({ editorFormatOnSave: !settings.editorFormatOnSave })}
/>
</SearchableSetting>
)
}
@@ -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({
)}
/>
<EditorFormatOnSaveSetting settings={settings} updateSettings={updateSettings} />
<SearchableSetting
title={translate(
'auto.components.settings.GeneralEditorSettingsSection.0df2e4fd12',
@@ -2,8 +2,10 @@ import { translate } from '@/i18n/i18n'
import { translateSearchKeyword } from './settings-search-keywords'
import { createLocalizedCatalog } from '@/i18n/localized-catalog'
import { getVisibleEditorTypographySearchEntries } from './editor-typography-search'
import { getFormatOnSaveSearchEntry } from './EditorFormatOnSaveSetting'
export const getGeneralEditorSearchEntries = createLocalizedCatalog(() => [
getFormatOnSaveSearchEntry(),
{
title: translate('auto.components.settings.general.search.ae21e806ce', 'Auto Save Files'),
description: translate(
@@ -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<Repo, 'editorFormatter'>) => void | Promise<boolean>
}): 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<void> => {
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 (
<SearchableSetting {...entry} forceVisible={forceVisible}>
<section className="space-y-4">
<SettingsSubsectionHeader title={entry.title} description={entry.description} />
<form
className="space-y-4"
onSubmit={(event) => {
event.preventDefault()
void save()
}}
>
<div className="space-y-2">
<Label htmlFor={commandId}>
{translate('settings.repositoryFormatter.command', 'Command')}
</Label>
<Input
id={commandId}
value={command}
onChange={(event) => setCommand(event.target.value)}
placeholder={translate(
'settings.repositoryFormatter.placeholder',
'npx --no-install prettier --stdin-filepath "${file}"'
)}
variant="code"
disabled={saving}
aria-invalid={Boolean(error)}
/>
<p className="text-xs text-muted-foreground">
{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.'
)}
</p>
</div>
<div className="space-y-2">
<Label htmlFor={includeId}>
{translate('settings.repositoryFormatter.include', 'Include globs (one per line)')}
</Label>
<Textarea
id={includeId}
value={include}
onChange={(event) => setInclude(event.target.value)}
variant="code"
rows={2}
disabled={saving}
/>
<p className="text-xs text-muted-foreground">
{translate(
'settings.repositoryFormatter.includeHelp',
'Match paths relative to the workspace root. No matching glob means no formatting. * and ** skip dot-prefixed names; list them explicitly, e.g. .github/**/*.yml. Enable Format on Save in General > Editor to use this command.'
)}
</p>
</div>
{error ? (
<p role="alert" className="text-xs text-destructive">
{error}
</p>
) : null}
<Button type="submit" size="sm" disabled={!changed || saving}>
{saving
? translate('settings.repositoryFormatter.saving', 'Saving…')
: translate('settings.repositoryFormatter.save', 'Save formatter')}
</Button>
</form>
</section>
</SearchableSetting>
)
}
@@ -32,6 +32,10 @@ import { RepositoryWorktreeDefaultsSection } from './execution/RepositoryWorktre
import { RepositoryJiraProjectSection } from './RepositoryJiraProjectSection'
import { getProjectRuntimeSessionSummary } from './execution/repository-runtime-session-summary'
import { getRepoOwnerWorktreeVisibilityDefaults } from '../../../store/worktree-visibility-defaults-by-host'
import {
RepositoryFormatterSection,
getRepositoryFormatterSearchEntry
} from './RepositoryFormatterSection'
type RepositoryPaneRepoUpdate = Omit<
Partial<Repo>,
@@ -361,6 +365,15 @@ export function RepositoryPane({
</section>
) : null,
hooksSection,
forceFullPaneForRepoMatch ||
matchesSettingsSearch(searchQuery, [getRepositoryFormatterSearchEntry()]) ? (
<RepositoryFormatterSection
key={`formatter-${repo.id}-${selectedHostId}`}
repo={repo}
updateRepo={updateSelectedRepo}
forceVisible={forceFullPaneForRepoMatch}
/>
) : null,
!isFolder &&
(forceFullPaneForRepoMatch || matchesSettingsSearch(searchQuery, sourceControlAiEntries)) ? (
<RepositorySourceControlAiSection
@@ -10,6 +10,7 @@ import { translateSearchKeyword } from '../settings-search-keywords'
import { getRepositoryGitAuthorSearchEntries } from './repository-git-author-search-entries'
import { getRepositoryGitHooksSearchEntries } from './execution/repository-git-hooks-search-entries'
import { getRepositoryGitWorktreeSearchEntries } from './execution/repository-git-worktree-search-entries'
import { getRepositoryFormatterSearchEntry } from './RepositoryFormatterSection'
type RepositoryPaneSearchOptions = {
isLocalWindowsProject?: boolean
@@ -26,6 +27,7 @@ export function getRepositoryPaneSearchEntries(
(Boolean(options.windowsRuntimeSupported) &&
getRepoExecutionHostId(repo) === LOCAL_EXECUTION_HOST_ID)
return [
getRepositoryFormatterSearchEntry(),
{
title: translate('components.RepositoryJiraProjectSection.title', 'Default Jira project'),
description: translate(
+22
View File
@@ -1093,6 +1093,23 @@
"keywordDelay": "delay",
"keywordCooldown": "cooldown",
"keywordSecurity": "security"
},
"editorFormatOnSave": {
"title": "Format on Save",
"description": "Format text before manual saves and auto save. Configure a formatter command in Settings > Projects. Off by default.",
"keywords": "formatter format on save prettier biome gofmt"
},
"repositoryFormatter": {
"title": "Formatter Command",
"description": "Use this project’s formatter for Format on Save.",
"saveFailed": "Could not save formatter settings.",
"command": "Command",
"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.",
"include": "Include globs (one per line)",
"includeHelp": "Match paths relative to the workspace root. No matching glob means no formatting. * and ** skip dot-prefixed names; list them explicitly, e.g. .github/**/*.yml. Enable Format on Save in General > Editor to use this command.",
"saving": "Saving…",
"save": "Save formatter",
"placeholder": "npx --no-install prettier --stdin-filepath \"${file}\""
}
},
"menu": {
@@ -20983,6 +21000,11 @@
},
"wslHostMark": {
"runsOn": "Runs on WSL: {{distro}} (Linux)"
},
"editor": {
"formatOnSave": {
"failed": "Could not format {{file}}. Saving without formatting: {{reason}}"
}
}
},
"dashboardPopout": {
@@ -42,6 +42,7 @@ export type RepoUpdate = Partial<
| 'repoIcon'
| 'upstream'
| 'hookSettings'
| 'editorFormatter'
| 'worktreeBaseRef'
| 'worktreeBasePath'
| 'kind'
+1
View File
@@ -55,6 +55,7 @@ export function buildDefaultSettings(args: {
appIcon: DEFAULT_APP_ICON_ID,
appFontFamily: args.appFontFamily,
editorAutoSave: false,
editorFormatOnSave: false,
editorAutoSaveDelayMs: args.editorAutoSaveDelayMs,
editorMinimapEnabled: false,
// Why empty: the editor keeps following the terminal font unless the user opts in.
+34
View File
@@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest'
import { editorFormatterArgv } from './editor-formatter'
describe('formatter command arguments', () => {
it('substitutes quoted paths once without interpreting shell syntax in filenames', () => {
const file = "/repo/a ' $(touch nope) ${relativeFile}.ts"
expect(
editorFormatterArgv(
'formatter --stdin-filepath "${file}" --relative=${relativeFile}',
file,
'src/${file}.ts',
'darwin'
)
).toEqual(['formatter', '--stdin-filepath', file, '--relative=src/${file}.ts'])
})
it('keeps native Windows paths and executable spaces intact', () => {
expect(
editorFormatterArgv(
'"C:\\Program Files\\Formatter\\fmt.exe" --path "${file}"',
'C:\\repo\\a b.ts',
'a b.ts',
'win32'
)
).toEqual(['C:\\Program Files\\Formatter\\fmt.exe', '--path', 'C:\\repo\\a b.ts'])
})
it.each(['', 'formatter "unfinished', 'formatter --write ${file}', 'gofmt -w ${file}'])(
'rejects invalid or file-writing command %s',
(command) => {
expect(() => editorFormatterArgv(command, '/repo/a.ts', 'a.ts', 'linux')).toThrow()
}
)
})
+37
View File
@@ -0,0 +1,37 @@
import { z } from 'zod'
import { tokenizeCustomCommandTemplate } from './commit-message-prompt'
export const editorFormatterSchema = z.object({
command: z.string().max(4096),
include: z.array(z.string().min(1).max(512)).max(32)
})
export type EditorFormatter = z.infer<typeof editorFormatterSchema>
export type EditorFormatResult = { kind: 'skipped' } | { kind: 'formatted'; text: string }
export const DEFAULT_FORMATTER_INCLUDE = '**/*.{ts,tsx,js,jsx,json,css,scss,html,md,yaml,yml}'
export function editorFormatterArgv(
command: string,
filePath: string,
relativePath: string,
platform: NodeJS.Platform
): string[] {
const parsed = tokenizeCustomCommandTemplate(command, platform === 'win32' ? 'literal' : 'escape')
if (!parsed.ok) {
throw new Error(parsed.error)
}
if (!parsed.tokens[0]) {
throw new Error('Configure a formatter command in project settings.')
}
if (parsed.tokens.some((token) => token === '--write' || token === '-w')) {
throw new Error('Use a stdin/stdout formatter command, without --write or -w.')
}
// Substitute after tokenization: paths are arguments, never shell syntax or another template.
return parsed.tokens.map((token) =>
token.replace(/\$\{(file|relativeFile)\}/g, (_, name: string) =>
name === 'file' ? filePath : relativePath
)
)
}
+1
View File
@@ -81,6 +81,7 @@ export type GlobalSettings = GlobalSettingsBase & {
appIcon: AppIconId
appFontFamily: string
editorAutoSave: boolean
editorFormatOnSave?: boolean
/** Days a release must be public before Orca offers it; absent/0 offers releases immediately. */
updateDelayDays?: number
editorAutoSaveDelayMs: number
+2
View File
@@ -8,6 +8,7 @@ import type { RepoSourceControlAiOverrides } from './source-control-ai-types'
import type { RepoProjectHostSetupMethod } from './project-types'
import type { JiraProjectReference } from './jira/jira-project-reference'
import type { OrcadRetainedMarker } from './orcad-retained-marker'
import type { EditorFormatter } from './editor-formatter'
export type RepoKind = 'git' | 'folder'
@@ -54,6 +55,7 @@ export type Repo = {
/** Optional repo-scoped workspace root override. Relative paths resolve from `path`. */
worktreeBasePath?: string
hookSettings?: RepoHookSettings
editorFormatter?: EditorFormatter
/** SSH target ID for remote repos. null/undefined = local. */
connectionId?: string | null
/** Runtime-host repos otherwise resemble local repos (connectionId: null); retain their explicit execution owner. */
@@ -25,6 +25,10 @@ export const FileWrite = FileMutationOpen.extend({
.refine((v): v is string => typeof v === 'string', { message: 'Missing file content' })
})
export const FileFormatText = FileMutationOpen.extend({
content: z.string().max(2 * 1024 * 1024)
})
// Why a separate method: a host too old to honor the precondition answers method_not_found
// instead of silently dropping the field and writing over newer disk content.
export const FileWriteDiskPreconditionParam = z.object({
@@ -9,6 +9,7 @@ import {
normalizeWorktreeVisibilitySourcePreferences
} from '../worktree/visibility-sources'
import { OptionalFiniteNumber, OptionalString } from './rpc-param-primitives'
import { editorFormatterSchema } from '../editor-formatter'
export const RepoSourceControlAiOverrides = z
.unknown()
@@ -51,6 +52,7 @@ export function createRepoUpdateSchema<T extends Readonly<Record<string, z.ZodTy
.optional(),
upstream: RepoUpstream,
hookSettings: z.unknown().optional(),
editorFormatter: editorFormatterSchema.optional(),
worktreeBaseRef: OptionalString,
worktreeBasePath: OptionalString,
kind: z.enum(['git', 'folder']).optional(),
+2
View File
@@ -219,6 +219,7 @@ import {
FileCommitUpload,
FileCopy,
FileDelete,
FileFormatText,
FileMutationOpen,
FileRename,
FileReplaceWithUpload,
@@ -1004,6 +1005,7 @@ export const RPC_PARAMS_BY_METHOD = {
'files.createDirNoClobber': FileMutationOpen,
'files.createFile': FileMutationOpen,
'files.delete': FileDelete,
'files.formatText': FileFormatText,
'files.list': WorktreeSelector,
'files.listAll': FileListAll,
'files.listMarkdownDocuments': WorktreeSelector,