mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
* fix(editor): keep every view of a shared record on its text; ask before a lossy UTF-8 save - A clean record's draft may reach a view's live model. Views of a record share one draft, so a commit that reads clean (a rich-mode undo that differs from disk only in trailing whitespace) is the record's text; refusing it left the source view showing undone text, and typing there resurrected it. - A rich view applies another view's text (or a reload) without an undo step, so its Cmd+Z no longer reverts the source view's typing or re-applies text undone there. - An outside change to a path (Source Control discard, watcher) now reaches views of the same record in other workspaces' panels, not only the named workspace's, so their cached copy of the discarded text is dropped and reloaded. - Explicit saves send the lossless-UTF-8 precondition. A non-UTF-8 file asks for confirmation (Save as UTF-8, destructive) before replacing characters that aren't valid UTF-8; declining leaves the bytes alone. The autosave-paused notice no longer promises a lossless conversion. - Dev builds resolve worker-thread entries from the main output folder when the caller was bundled into a shared chunk (packaged builds resolve from resources and are unchanged). Co-Authored-By: Claude <noreply@anthropic.com> * fix(editor): say why save-and-close refused a non-UTF-8 file --------- Co-authored-by: Claude <noreply@anthropic.com>
157 lines
6.2 KiB
TypeScript
157 lines
6.2 KiB
TypeScript
import { getDiskBaselineSignature, getWrittenContentDiskSignature } from './disk-baseline-signature'
|
|
|
|
// Why: the host serves `files.writeIfUnchanged`; without it a client compares from its side first.
|
|
export const FILE_WRITE_DISK_PRECONDITION_RUNTIME_CAPABILITY =
|
|
'files.write.disk-precondition.v1' as const
|
|
|
|
// Why separate: v1 hosts can rewrite identical text and cannot enforce lossless automatic saves.
|
|
export const FILE_WRITE_IDENTICAL_RUNTIME_CAPABILITY = 'files.write.identical-noop.v1' as const
|
|
|
|
export const FILE_WRITE_IF_UNCHANGED_METHOD = 'files.writeIfUnchanged'
|
|
|
|
/**
|
|
* Whether the host's guarded write can be sent as is. A v1 host can't check bytes or skip identical
|
|
* text, so only saves that must refuse non-UTF-8 files pay for a client-side read first.
|
|
*/
|
|
export function hostEnforcesPrecondition(
|
|
capabilities: readonly unknown[],
|
|
precondition: FileWriteDiskPrecondition
|
|
): boolean {
|
|
return (
|
|
capabilities.includes(FILE_WRITE_IDENTICAL_RUNTIME_CAPABILITY) ||
|
|
(capabilities.includes(FILE_WRITE_DISK_PRECONDITION_RUNTIME_CAPABILITY) &&
|
|
precondition.requireLosslessUtf8 !== true)
|
|
)
|
|
}
|
|
|
|
/** Refuse the write unless disk still holds the content this signature was taken over. */
|
|
export type FileWriteDiskPrecondition = {
|
|
expectedSignature: string
|
|
/** A missing file is written (recreated) instead of refused. */
|
|
allowMissing?: boolean
|
|
/** Refuse a file that isn't valid UTF-8 rather than convert it unasked. */
|
|
requireLosslessUtf8?: boolean
|
|
}
|
|
|
|
/** `not-utf8`: a save met bytes it can't round-trip; older clients read it as `changed`. */
|
|
export type FileWriteDiskConflict = 'changed' | 'missing' | 'not-utf8'
|
|
|
|
export type FileWriteDiskComparison = FileWriteDiskConflict | 'identical' | null
|
|
|
|
export type FileWriteResult =
|
|
| { ok: true; unchanged?: boolean }
|
|
| { ok: false; diskConflict: FileWriteDiskConflict }
|
|
|
|
export const FILE_WRITE_OK: FileWriteResult = { ok: true }
|
|
export const FILE_WRITE_UNCHANGED: FileWriteResult = { ok: true, unchanged: true }
|
|
|
|
/** `current` is the decoded disk text, `null` when missing, `undefined` when not comparable text; `content` is the text being saved. */
|
|
export function compareDiskWritePrecondition(
|
|
precondition: FileWriteDiskPrecondition,
|
|
current: string | null | undefined,
|
|
content: string
|
|
): FileWriteDiskComparison {
|
|
if (current === null) {
|
|
return precondition.allowMissing === true ? null : 'missing'
|
|
}
|
|
if (current === undefined) {
|
|
return 'changed'
|
|
}
|
|
// Why: U+FFFD may stand for bytes a decode dropped, so decoded equality proves nothing.
|
|
if (current === content && !current.includes('\uFFFD')) {
|
|
return 'identical'
|
|
}
|
|
if (getDiskBaselineSignature(current) !== precondition.expectedSignature) {
|
|
return 'changed'
|
|
}
|
|
// Why: decoded text can't tell a real U+FFFD from replaced bytes, so a reader of text stays conservative.
|
|
return precondition.requireLosslessUtf8 && current.includes('\uFFFD') ? 'not-utf8' : null
|
|
}
|
|
|
|
/** Why: old hosts answer `{ ok: true }` or nothing; only an explicit `ok: false` is a refusal. */
|
|
export function parseFileWriteResult(value: unknown): FileWriteResult {
|
|
if (typeof value !== 'object' || value === null || !('ok' in value)) {
|
|
return FILE_WRITE_OK
|
|
}
|
|
if (value.ok !== false) {
|
|
return value.ok === true && 'unchanged' in value && value.unchanged === true
|
|
? FILE_WRITE_UNCHANGED
|
|
: FILE_WRITE_OK
|
|
}
|
|
const diskConflict = 'diskConflict' in value ? value.diskConflict : undefined
|
|
return {
|
|
ok: false,
|
|
diskConflict:
|
|
diskConflict === 'missing' || diskConflict === 'not-utf8' ? diskConflict : 'changed'
|
|
}
|
|
}
|
|
|
|
/** Only a not-found answer proves the file is gone; a lost transport proves nothing. */
|
|
function isConfirmedFileNotFoundError(error: unknown): boolean {
|
|
if (!(error instanceof Error)) {
|
|
return false
|
|
}
|
|
// Other codes (an RPC envelope's 'runtime_error') carry no errno, so the wording decides.
|
|
const code = 'code' in error ? error.code : undefined
|
|
if (typeof code === 'string' && ERRNO_CODE.test(code)) {
|
|
return code === 'ENOENT'
|
|
}
|
|
// Why only a leading wrapper is stripped: the rest may quote a path, which can spell anything.
|
|
return NOT_FOUND_MESSAGE.test(error.message.replace(IPC_ERROR_WRAPPER, ''))
|
|
}
|
|
|
|
const ERRNO_CODE = /^E[A-Z0-9]+$/
|
|
const IPC_ERROR_WRAPPER = /^Error invoking remote method '[^']*': (?:Error: )?/
|
|
const NOT_FOUND_MESSAGE = /^(?:ENOENT: no such file or directory|No such file(?: or directory)?$)/i
|
|
|
|
// Why both: hosts answer with the `file_too_large` code (carried as the message); local readers in prose.
|
|
function isFileTooLargeToReadError(error: unknown): boolean {
|
|
if (!(error instanceof Error)) {
|
|
return false
|
|
}
|
|
const code = 'code' in error ? error.code : undefined
|
|
return (
|
|
code === 'file_too_large' ||
|
|
error.message === 'file_too_large' ||
|
|
/\btoo large\b|\bis larger than\b/i.test(error.message)
|
|
)
|
|
}
|
|
|
|
/** Settles a precondition whose disk read failed; rethrows anything that doesn't prove the disk state. */
|
|
export function diskConflictFromReadFailure(
|
|
precondition: FileWriteDiskPrecondition,
|
|
error: unknown
|
|
): FileWriteDiskConflict | null {
|
|
if (isConfirmedFileNotFoundError(error)) {
|
|
return precondition.allowMissing === true ? null : 'missing'
|
|
}
|
|
// Why: the baseline was read whole, so content too big to read now cannot match it.
|
|
if (isFileTooLargeToReadError(error)) {
|
|
return 'changed'
|
|
}
|
|
throw error
|
|
}
|
|
|
|
/** For writers that create or restore a file: write only where none exists or it already holds `content`. */
|
|
export function missingOrIdenticalPrecondition(content: string): FileWriteDiskPrecondition {
|
|
return { expectedSignature: getWrittenContentDiskSignature(content), allowMissing: true }
|
|
}
|
|
|
|
export const FILE_ALREADY_EXISTS_DIFFERENTLY_MESSAGE =
|
|
'A different file already exists at this path, so it was left unchanged.'
|
|
|
|
/** A write the host refused before writing anything, so retrying it later is safe. */
|
|
export class FileWriteRefusedError extends Error {
|
|
constructor() {
|
|
super(FILE_ALREADY_EXISTS_DIFFERENTLY_MESSAGE)
|
|
this.name = 'FileWriteRefusedError'
|
|
}
|
|
}
|
|
|
|
/** Turns a refusal into the error a caller's existing failure path already reports. */
|
|
export function assertFileWriteApplied(result: FileWriteResult): void {
|
|
if (!result.ok) {
|
|
throw new FileWriteRefusedError()
|
|
}
|
|
}
|