Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions apps/gateway/tests/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -941,6 +941,27 @@ describe('gateway', () => {
headers,
})
const before = FileContentSchema.parse(read.json())
for (const path of ['slides.md', 'new/deck/slides.md']) {
const rejected = await app.inject({
method: 'PUT',
url: '/api/v1/workspace/files/content',
headers,
payload: { path, content: 'bad\0text' },
})
expect(rejected.statusCode).toBe(400)
expect(rejected.json()).toMatchObject({
error: { code: 'INVALID_REQUEST', retryable: false },
})
}
expect(readFileSync(join(root, 'slides.md'), 'utf8')).toBe(before.content)
expect(existsSync(join(root, 'new'))).toBe(false)
const unchanged = await app.inject({
method: 'GET',
url: '/api/v1/workspace/files/content?path=slides.md',
headers,
})
expect(FileContentSchema.parse(unchanged.json())).toEqual(before)

const write = await app.inject({
method: 'PUT',
url: '/api/v1/workspace/files/content',
Expand Down
56 changes: 46 additions & 10 deletions apps/web/src/stores/files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ export const useFilesStore = defineStore('files', () => {
const latestEvent = shallowRef<ServerEvent>()
const client = useGatewayClient()
const retry = createConnectivityRetry()
// A reopen starts a new session even when its path is unchanged.
let session = { opening: false }
let readVersion = 0
let draftVersion = 0

const dirty = computed(
() =>
Expand Down Expand Up @@ -57,35 +61,57 @@ export const useFilesStore = defineStore('files', () => {
}

async function openFile(path: string): Promise<void> {
const openedSession = { opening: true }
session = openedSession
saving.value = false
error.value = undefined
externalChange.value = false
if (selectedFile.value?.path !== path) {
selectedFile.value = undefined
draft.value = ''
}
const edits = draftVersion
try {
const content = await client.readFile(path)
if (session !== openedSession) return
selectedFile.value = content
draft.value = content.content
if (draftVersion === edits) draft.value = content.content
} catch (cause) {
if (session !== openedSession) return
error.value = cause instanceof Error ? cause.message : '无法读取文件'
} finally {
openedSession.opening = false
}
}

function updateDraft(content: string): void {
if (draft.value !== content) draftVersion++
draft.value = content
}

async function save(): Promise<void> {
if (!selectedFile.value || !dirty.value || saving.value) return
if (!selectedFile.value || session.opening || !dirty.value || saving.value)
return
const savedSession = session
const selected = selectedFile.value
const content = draft.value
const edits = draftVersion
// Reads begun before this write cannot replace its acknowledged baseline.
readVersion++
saving.value = true
error.value = undefined
try {
const updated = await client.writeFile({
path: selectedFile.value.path,
content: draft.value,
expectedRevision: selectedFile.value.revision,
path: selected.path,
content,
expectedRevision: selected.revision,
})
if (session !== savedSession) return
selectedFile.value = updated
draft.value = updated.content
if (draftVersion === edits) draft.value = updated.content
externalChange.value = false
} catch (cause) {
if (session !== savedSession) return
if (isGatewayError(cause) && cause.status === 409) {
externalChange.value = true
error.value =
Expand All @@ -94,7 +120,7 @@ export const useFilesStore = defineStore('files', () => {
error.value = cause instanceof Error ? cause.message : '保存失败'
}
} finally {
saving.value = false
if (session === savedSession) saving.value = false
}
}

Expand All @@ -107,19 +133,28 @@ export const useFilesStore = defineStore('files', () => {
? String(event.data.path)
: undefined
if (!currentPath || eventPath !== currentPath) return
if (dirty.value) {
if (dirty.value || saving.value) {
externalChange.value = true
return
}
void openFile(currentPath)
}

async function resyncWorkspace(): Promise<void> {
await loadTree()
const syncedSession = session
const selected = selectedFile.value
if (!selected) return
const read = ++readVersion
const ownsRead = () =>
session === syncedSession &&
!session.opening &&
readVersion === read &&
selectedFile.value === selected &&
!saving.value
await loadTree()
if (!selected || !ownsRead()) return
try {
const current = await client.readFile(selected.path)
if (!ownsRead()) return
if (current.revision === selected.revision) return
if (dirty.value) {
externalChange.value = true
Expand All @@ -129,6 +164,7 @@ export const useFilesStore = defineStore('files', () => {
draft.value = current.content
externalChange.value = false
} catch (cause) {
if (!ownsRead()) return
error.value = cause instanceof Error ? cause.message : '无法重新同步文件'
}
}
Expand Down
Loading
Loading