mirror of
https://github.com/stablyai/orca.git
synced 2026-11-13 17:37:40 +00:00
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:
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user