diff --git a/src/renderer/src/components/editor/markdown-document-list-request.test.ts b/src/renderer/src/components/editor/markdown-document-list-request.test.ts new file mode 100644 index 00000000000..f715bcb8f0f --- /dev/null +++ b/src/renderer/src/components/editor/markdown-document-list-request.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it, vi } from 'vitest' +import type { MarkdownDocument } from '../../../../shared/types' +import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client' +import { + getMarkdownDocumentListRequestKey, + requestSharedMarkdownDocumentList +} from './markdown-document-list-request' + +function context(overrides: Partial = {}): RuntimeFileOperationArgs { + return { + settings: { activeRuntimeEnvironmentId: null }, + worktreeId: 'worktree-1', + worktreePath: '/repo', + ...overrides + } +} + +function deferred(): { + promise: Promise + reject: (error: Error) => void + resolve: (value: T) => void +} { + let resolve!: (value: T) => void + let reject!: (error: Error) => void + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise + reject = rejectPromise + }) + return { promise, reject, resolve } +} + +describe('shared Markdown document list requests', () => { + it('collapses concurrent pane scans and releases the result after settlement', async () => { + const pending = deferred() + const load = vi.fn(() => pending.promise) + const requests = Array.from({ length: 200 }, () => + requestSharedMarkdownDocumentList(context(), '/repo', load) + ) + + expect(load).toHaveBeenCalledTimes(1) + expect(new Set(requests).size).toBe(1) + + const documents = [{ filePath: '/repo/README.md', relativePath: 'README.md' }] + pending.resolve(documents as MarkdownDocument[]) + await expect(Promise.all(requests)).resolves.toEqual( + Array.from({ length: 200 }, () => documents) + ) + + const nextDocuments = [{ filePath: '/repo/notes.md', relativePath: 'notes.md' }] + load.mockResolvedValueOnce(nextDocuments as MarkdownDocument[]) + await expect(requestSharedMarkdownDocumentList(context(), '/repo', load)).resolves.toBe( + nextDocuments + ) + expect(load).toHaveBeenCalledTimes(2) + }) + + it('keeps runtime routes isolated and encodes unusual paths losslessly', () => { + const local = context() + const ssh = context({ connectionId: 'ssh-1' }) + const runtime = context({ settings: { activeRuntimeEnvironmentId: ' runtime-1 ' } }) + const unusualRoot = '/repo\nwith-newline' + + expect(getMarkdownDocumentListRequestKey(local, unusualRoot)).not.toBe( + getMarkdownDocumentListRequestKey(ssh, unusualRoot) + ) + expect(getMarkdownDocumentListRequestKey(ssh, unusualRoot)).not.toBe( + getMarkdownDocumentListRequestKey(runtime, unusualRoot) + ) + expect(JSON.parse(getMarkdownDocumentListRequestKey(local, unusualRoot)).at(-1)).toBe( + unusualRoot + ) + }) + + it('clears rejected requests so a retry can run', async () => { + const failure = deferred() + const load = vi.fn(() => failure.promise) + const first = requestSharedMarkdownDocumentList(context(), '/retry', load) + const second = requestSharedMarkdownDocumentList(context(), '/retry', load) + + failure.reject(new Error('offline')) + await expect(first).rejects.toThrow('offline') + await expect(second).rejects.toThrow('offline') + + load.mockResolvedValueOnce([]) + await expect(requestSharedMarkdownDocumentList(context(), '/retry', load)).resolves.toEqual([]) + expect(load).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/renderer/src/components/editor/markdown-document-list-request.ts b/src/renderer/src/components/editor/markdown-document-list-request.ts new file mode 100644 index 00000000000..29ecd69d14c --- /dev/null +++ b/src/renderer/src/components/editor/markdown-document-list-request.ts @@ -0,0 +1,47 @@ +import type { MarkdownDocument } from '../../../../shared/types' +import { + listRuntimeMarkdownDocuments, + type RuntimeFileOperationArgs +} from '@/runtime/runtime-file-client' + +type MarkdownDocumentListLoader = ( + context: RuntimeFileOperationArgs, + rootPath: string +) => Promise + +const inFlightMarkdownDocumentLists = new Map>() + +export function getMarkdownDocumentListRequestKey( + context: RuntimeFileOperationArgs, + rootPath: string +): string { + return JSON.stringify([ + context.settings?.activeRuntimeEnvironmentId?.trim() ?? '', + context.connectionId ?? '', + context.worktreeId ?? '', + context.worktreePath ?? '', + rootPath + ]) +} + +export function requestSharedMarkdownDocumentList( + context: RuntimeFileOperationArgs, + rootPath: string, + load: MarkdownDocumentListLoader = listRuntimeMarkdownDocuments +): Promise { + const key = getMarkdownDocumentListRequestKey(context, rootPath) + const existing = inFlightMarkdownDocumentLists.get(key) + if (existing) { + return existing + } + + // Why: split Markdown panes mount together and otherwise launch identical + // whole-worktree local/SSH scans; share only concurrent work so freshness is unchanged. + const request = load(context, rootPath).finally(() => { + if (inFlightMarkdownDocumentLists.get(key) === request) { + inFlightMarkdownDocumentLists.delete(key) + } + }) + inFlightMarkdownDocumentLists.set(key, request) + return request +} diff --git a/src/renderer/src/components/editor/useMarkdownDocuments.ts b/src/renderer/src/components/editor/useMarkdownDocuments.ts index 1dd3c22c6fd..ba68fa390cd 100644 --- a/src/renderer/src/components/editor/useMarkdownDocuments.ts +++ b/src/renderer/src/components/editor/useMarkdownDocuments.ts @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { MarkdownDocument } from '../../../../shared/types' import { useAppStore } from '@/store' import { getConnectionId } from '@/lib/connection-context' -import { listRuntimeMarkdownDocuments, statRuntimePath } from '@/runtime/runtime-file-client' +import { statRuntimePath } from '@/runtime/runtime-file-client' import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client' import type { MarkdownViewMode, OpenFile } from '@/store/slices/editor' import { @@ -11,6 +11,7 @@ import { resolveMarkdownDocLink } from './markdown-doc-links' import { selectMarkdownDocumentWorktreePath } from './markdown-document-worktree-path-selector' +import { requestSharedMarkdownDocumentList } from './markdown-document-list-request' type OpenMarkdownDocumentOptions = { anchor?: string | null @@ -60,7 +61,7 @@ export function useMarkdownDocuments( const requestId = requestRef.current + 1 requestRef.current = requestId try { - const documents = await listRuntimeMarkdownDocuments( + const documents = await requestSharedMarkdownDocumentList( { settings: settingsForRuntimeOwner( useAppStore.getState().settings,