mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-08 16:39:42 +02:00
fix(file-viewer): COD-341 deduplicate normal tree loads
This commit is contained in:
+204
-47
@@ -2990,8 +2990,9 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
ownerSessionId,
|
ownerSessionId,
|
||||||
view: 'normal',
|
view: 'normal',
|
||||||
normalState: this.fileBrowserData
|
normalState: this.fileBrowserData
|
||||||
? { ownerSessionId, showHidden, data: this.fileBrowserData }
|
? { sessionId: ownerSessionId, showHidden, treeEpoch: 0, phase: 'ready', data: this.fileBrowserData }
|
||||||
: null,
|
: null,
|
||||||
|
treeInFlight: null,
|
||||||
inFlight: null,
|
inFlight: null,
|
||||||
matches: [],
|
matches: [],
|
||||||
deferredDirectoryTarget: null,
|
deferredDirectoryTarget: null,
|
||||||
@@ -3013,6 +3014,104 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
statusEl.textContent = `${totalFiles} files, ${totalDirectories} dirs${truncated ? ' (truncated)' : ''}${showHidden ? ' · hidden shown' : ''}`;
|
statusEl.textContent = `${totalFiles} files, ${totalDirectories} dirs${truncated ? ' (truncated)' : ''}${showHidden ? ' · hidden shown' : ''}`;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
_isFileBrowserNormalCompatible(candidate, sessionId, showHidden, treeEpoch) {
|
||||||
|
return (
|
||||||
|
candidate?.sessionId === sessionId &&
|
||||||
|
candidate.showHidden === showHidden &&
|
||||||
|
candidate.treeEpoch === treeEpoch
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
_isFileBrowserTreeContextCurrent(request, requireCurrentRecord = false) {
|
||||||
|
const state = this._ensureFileBrowserState();
|
||||||
|
return (
|
||||||
|
(!requireCurrentRecord || state.treeInFlight === request) &&
|
||||||
|
state.ownerSessionId === request.sessionId &&
|
||||||
|
state.treeEpoch === request.treeEpoch &&
|
||||||
|
(this.fileBrowserShowHidden === true) === request.showHidden
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
_canRenderFileBrowserNormal(normalState) {
|
||||||
|
const state = this._ensureFileBrowserState();
|
||||||
|
return (
|
||||||
|
state.view === 'normal' &&
|
||||||
|
this.activeSessionId === normalState?.sessionId &&
|
||||||
|
this._isFileBrowserNormalCompatible(
|
||||||
|
normalState,
|
||||||
|
state.ownerSessionId,
|
||||||
|
this.fileBrowserShowHidden === true,
|
||||||
|
state.treeEpoch,
|
||||||
|
) &&
|
||||||
|
this.$('fileBrowserPanel')?.classList.contains('visible') === true
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
_renderFileBrowserNormalState(normalState) {
|
||||||
|
if (!normalState || !this._canRenderFileBrowserNormal(normalState)) return;
|
||||||
|
const treeEl = this.$('fileBrowserTree');
|
||||||
|
const statusEl = this.$('fileBrowserStatus');
|
||||||
|
if (!treeEl) return;
|
||||||
|
|
||||||
|
if (normalState.phase === 'loading') {
|
||||||
|
this.fileBrowserData = null;
|
||||||
|
treeEl.innerHTML = `<div class="file-browser-loading">${escapeHtml('Loading files...')}</div>`;
|
||||||
|
if (statusEl) statusEl.textContent = 'Loading files...';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalState.phase === 'error') {
|
||||||
|
this.fileBrowserData = null;
|
||||||
|
const detail = normalState.error && normalState.error !== 'Failed to load files'
|
||||||
|
? `: ${normalState.error}`
|
||||||
|
: '';
|
||||||
|
const message = `Failed to load files${detail}`;
|
||||||
|
treeEl.innerHTML = `<div class="file-browser-empty">${escapeHtml(message)}</div>`;
|
||||||
|
if (statusEl) statusEl.textContent = message;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalState.phase !== 'ready') return;
|
||||||
|
this.fileBrowserData = normalState.data;
|
||||||
|
this._setFileBrowserExpandDisabled(false);
|
||||||
|
this.renderFileBrowserTree(normalState.sessionId);
|
||||||
|
this._renderFileBrowserNormalStatus(normalState.data, normalState.showHidden);
|
||||||
|
},
|
||||||
|
|
||||||
|
_validateFileBrowserTreeEnvelope(result) {
|
||||||
|
if (!result || typeof result !== 'object' || result.success !== true) return null;
|
||||||
|
const data = result.data;
|
||||||
|
if (!data || typeof data !== 'object' || !Array.isArray(data.tree)) return null;
|
||||||
|
if (data.mode === 'search') return null;
|
||||||
|
if (
|
||||||
|
typeof data.totalFiles !== 'number' ||
|
||||||
|
!Number.isFinite(data.totalFiles) ||
|
||||||
|
data.totalFiles < 0 ||
|
||||||
|
typeof data.totalDirectories !== 'number' ||
|
||||||
|
!Number.isFinite(data.totalDirectories) ||
|
||||||
|
data.totalDirectories < 0 ||
|
||||||
|
typeof data.truncated !== 'boolean'
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const validNodes = nodes => nodes.every(node => {
|
||||||
|
if (!node || typeof node !== 'object') return false;
|
||||||
|
if (typeof node.name !== 'string' || typeof node.path !== 'string') return false;
|
||||||
|
if (node.type !== 'file' && node.type !== 'directory') return false;
|
||||||
|
if (node.size !== undefined && (typeof node.size !== 'number' || !Number.isFinite(node.size))) return false;
|
||||||
|
if (node.extension !== undefined && typeof node.extension !== 'string') return false;
|
||||||
|
if (node.children !== undefined && (!Array.isArray(node.children) || !validNodes(node.children))) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
return validNodes(data.tree) ? data : null;
|
||||||
|
},
|
||||||
|
|
||||||
|
_normalizeFileBrowserTreeError(error) {
|
||||||
|
return typeof error?.message === 'string' && error.message ? error.message : 'Failed to load files';
|
||||||
|
},
|
||||||
|
|
||||||
_validateFileBrowserSearchEnvelope(result) {
|
_validateFileBrowserSearchEnvelope(result) {
|
||||||
if (!result || typeof result !== 'object' || result.success !== true) return null;
|
if (!result || typeof result !== 'object' || result.success !== true) return null;
|
||||||
const data = result.data;
|
const data = result.data;
|
||||||
@@ -3039,7 +3138,6 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
const panel = this.$('fileBrowserPanel');
|
const panel = this.$('fileBrowserPanel');
|
||||||
return (
|
return (
|
||||||
state.searchEpoch === request.epoch &&
|
state.searchEpoch === request.epoch &&
|
||||||
state.treeEpoch === request.treeEpoch &&
|
|
||||||
state.ownerSessionId === request.ownerSessionId &&
|
state.ownerSessionId === request.ownerSessionId &&
|
||||||
this.activeSessionId === request.ownerSessionId &&
|
this.activeSessionId === request.ownerSessionId &&
|
||||||
(this.fileBrowserShowHidden === true) === request.showHidden &&
|
(this.fileBrowserShowHidden === true) === request.showHidden &&
|
||||||
@@ -3064,46 +3162,89 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
this._syncFileBrowserHiddenBtn();
|
this._syncFileBrowserHiddenBtn();
|
||||||
// Expanded-directory state is deliberately preserved so toggling does not
|
// Expanded-directory state is deliberately preserved so toggling does not
|
||||||
// collapse the tree the user just navigated.
|
// collapse the tree the user just navigated.
|
||||||
if (this.activeSessionId) await this.loadFileBrowser(this.activeSessionId);
|
if (this.activeSessionId) await this.loadFileBrowser(this.activeSessionId, { force: true });
|
||||||
},
|
},
|
||||||
|
|
||||||
async loadFileBrowser(sessionId) {
|
loadFileBrowser(sessionId, { force = false } = {}) {
|
||||||
if (!sessionId) return;
|
if (!sessionId) return undefined;
|
||||||
|
|
||||||
const state = this._ensureFileBrowserState();
|
const state = this._ensureFileBrowserState();
|
||||||
state.treeEpoch++;
|
|
||||||
state.ownerSessionId = sessionId;
|
|
||||||
const treeEl = this.$('fileBrowserTree');
|
const treeEl = this.$('fileBrowserTree');
|
||||||
const statusEl = this.$('fileBrowserStatus');
|
|
||||||
this._syncFileBrowserHiddenBtn();
|
this._syncFileBrowserHiddenBtn();
|
||||||
if (!treeEl) return;
|
if (!treeEl) return undefined;
|
||||||
|
if (!state.ownerSessionId) state.ownerSessionId = sessionId;
|
||||||
|
if (state.ownerSessionId !== sessionId) return undefined;
|
||||||
|
|
||||||
// Show loading state
|
if (force) state.treeEpoch++;
|
||||||
treeEl.innerHTML = '<div class="file-browser-loading">Loading files...</div>';
|
const showHidden = this.fileBrowserShowHidden === true;
|
||||||
|
const treeEpoch = state.treeEpoch;
|
||||||
try {
|
const inFlight = state.treeInFlight;
|
||||||
const showHidden = this.fileBrowserShowHidden === true;
|
if (
|
||||||
const res = await fetch(`/api/sessions/${sessionId}/files?depth=5&showHidden=${showHidden}`);
|
!force &&
|
||||||
if (!res.ok) throw new Error('Failed to load files');
|
this._isFileBrowserNormalCompatible(inFlight, sessionId, showHidden, treeEpoch)
|
||||||
|
) {
|
||||||
const result = await res.json();
|
return inFlight.promise;
|
||||||
if (!result.success) throw new Error(result.error || 'Failed to load files');
|
|
||||||
|
|
||||||
this.fileBrowserData = result.data;
|
|
||||||
state.normalState = { ownerSessionId: sessionId, showHidden, data: result.data };
|
|
||||||
if (state.filter.trim() === '') {
|
|
||||||
state.view = 'normal';
|
|
||||||
state.matches = [];
|
|
||||||
this._setFileBrowserExpandDisabled(false);
|
|
||||||
this.renderFileBrowserTree(sessionId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update status
|
|
||||||
if (statusEl && state.view === 'normal') this._renderFileBrowserNormalStatus(result.data, showHidden);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to load file browser:', err);
|
|
||||||
treeEl.innerHTML = `<div class="file-browser-empty">Failed to load files: ${escapeHtml(err.message)}</div>`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const settled = state.normalState;
|
||||||
|
if (
|
||||||
|
!force &&
|
||||||
|
this._isFileBrowserNormalCompatible(settled, sessionId, showHidden, treeEpoch) &&
|
||||||
|
(settled.phase === 'ready' || settled.phase === 'error')
|
||||||
|
) {
|
||||||
|
if (settled.phase === 'ready') this.fileBrowserData = settled.data;
|
||||||
|
this._renderFileBrowserNormalState(settled);
|
||||||
|
return Promise.resolve(settled);
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadingState = { sessionId, showHidden, treeEpoch, phase: 'loading' };
|
||||||
|
state.normalState = loadingState;
|
||||||
|
this.fileBrowserData = null;
|
||||||
|
this._renderFileBrowserNormalState(loadingState);
|
||||||
|
|
||||||
|
const record = { sessionId, showHidden, treeEpoch, promise: null };
|
||||||
|
const request = (async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/sessions/${encodeURIComponent(sessionId)}/files?depth=5&showHidden=${showHidden}`,
|
||||||
|
);
|
||||||
|
if (!res.ok) throw new Error('Failed to load files');
|
||||||
|
const result = await res.json();
|
||||||
|
const data = this._validateFileBrowserTreeEnvelope(result);
|
||||||
|
if (!data) {
|
||||||
|
const detail = result && typeof result === 'object' && typeof result.error === 'string'
|
||||||
|
? result.error
|
||||||
|
: 'Failed to load files';
|
||||||
|
throw new Error(detail);
|
||||||
|
}
|
||||||
|
if (!this._isFileBrowserTreeContextCurrent(record, true)) return;
|
||||||
|
|
||||||
|
const nextNormalState = { sessionId, showHidden, treeEpoch, phase: 'ready', data };
|
||||||
|
state.normalState = nextNormalState;
|
||||||
|
this.fileBrowserData = data;
|
||||||
|
this._completeDeferredFileBrowserDirectory?.(nextNormalState);
|
||||||
|
this._renderFileBrowserNormalState(nextNormalState);
|
||||||
|
} catch (error) {
|
||||||
|
if (!this._isFileBrowserTreeContextCurrent(record, true)) return;
|
||||||
|
const nextNormalState = {
|
||||||
|
sessionId,
|
||||||
|
showHidden,
|
||||||
|
treeEpoch,
|
||||||
|
phase: 'error',
|
||||||
|
error: this._normalizeFileBrowserTreeError(error),
|
||||||
|
};
|
||||||
|
state.normalState = nextNormalState;
|
||||||
|
this.fileBrowserData = null;
|
||||||
|
this._completeDeferredFileBrowserDirectory?.(nextNormalState);
|
||||||
|
console.error('Failed to load file browser:', error);
|
||||||
|
this._renderFileBrowserNormalState(nextNormalState);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
record.promise = request.finally(() => {
|
||||||
|
if (state.treeInFlight === record) state.treeInFlight = null;
|
||||||
|
});
|
||||||
|
state.treeInFlight = record;
|
||||||
|
return record.promise;
|
||||||
},
|
},
|
||||||
|
|
||||||
renderFileBrowserTree(ownerSessionId) {
|
renderFileBrowserTree(ownerSessionId) {
|
||||||
@@ -3111,7 +3252,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
if (!treeEl || !this.fileBrowserData) return;
|
if (!treeEl || !this.fileBrowserData) return;
|
||||||
|
|
||||||
const state = this._ensureFileBrowserState();
|
const state = this._ensureFileBrowserState();
|
||||||
const owner = ownerSessionId || state.normalState?.ownerSessionId || state.ownerSessionId || this.activeSessionId;
|
const owner = ownerSessionId || state.normalState?.sessionId || state.ownerSessionId || this.activeSessionId;
|
||||||
if (!owner) return;
|
if (!owner) return;
|
||||||
|
|
||||||
const { tree } = this.fileBrowserData;
|
const { tree } = this.fileBrowserData;
|
||||||
@@ -3216,12 +3357,14 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
const normal = state.normalState;
|
const normal = state.normalState;
|
||||||
if (
|
if (
|
||||||
ownerSessionId &&
|
ownerSessionId &&
|
||||||
normal?.ownerSessionId === ownerSessionId &&
|
this._isFileBrowserNormalCompatible(
|
||||||
normal.showHidden === (this.fileBrowserShowHidden === true)
|
normal,
|
||||||
|
ownerSessionId,
|
||||||
|
this.fileBrowserShowHidden === true,
|
||||||
|
state.treeEpoch,
|
||||||
|
)
|
||||||
) {
|
) {
|
||||||
this.fileBrowserData = normal.data;
|
this._renderFileBrowserNormalState(normal);
|
||||||
this.renderFileBrowserTree(ownerSessionId);
|
|
||||||
this._renderFileBrowserNormalStatus(normal.data, normal.showHidden);
|
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -3366,14 +3509,28 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
},
|
},
|
||||||
|
|
||||||
refreshFileBrowser() {
|
refreshFileBrowser() {
|
||||||
if (this.activeSessionId) {
|
const state = this._ensureFileBrowserState();
|
||||||
this.fileBrowserExpandedDirs.clear();
|
if (state.inFlight?.timer !== undefined && state.inFlight?.timer !== null) {
|
||||||
this.fileBrowserFilter = '';
|
clearTimeout(state.inFlight.timer);
|
||||||
this.fileBrowserAllExpanded = false;
|
|
||||||
const searchInput = this.$('fileBrowserSearch');
|
|
||||||
if (searchInput) searchInput.value = '';
|
|
||||||
this.loadFileBrowser(this.activeSessionId);
|
|
||||||
}
|
}
|
||||||
|
state.inFlight = null;
|
||||||
|
state.searchEpoch++;
|
||||||
|
state.filter = '';
|
||||||
|
state.matches = [];
|
||||||
|
state.deferredDirectoryTarget = null;
|
||||||
|
state.view = 'normal';
|
||||||
|
this.fileBrowserFilter = '';
|
||||||
|
this.fileBrowserExpandedDirs.clear();
|
||||||
|
this.fileBrowserAllExpanded = false;
|
||||||
|
this._setFileBrowserExpandDisabled(false);
|
||||||
|
const expandBtn = this.$('fileBrowserExpandBtn');
|
||||||
|
if (expandBtn) expandBtn.innerHTML = '\u229E';
|
||||||
|
const searchInput = this.$('fileBrowserSearch');
|
||||||
|
if (searchInput) searchInput.value = '';
|
||||||
|
|
||||||
|
const ownerSessionId = state.ownerSessionId || this.activeSessionId;
|
||||||
|
if (!ownerSessionId || this.activeSessionId !== ownerSessionId) return undefined;
|
||||||
|
return this.loadFileBrowser(ownerSessionId, { force: true });
|
||||||
},
|
},
|
||||||
|
|
||||||
// Header "File Viewer" button (opt-in via App Settings → Header Displays →
|
// Header "File Viewer" button (opt-in via App Settings → Header Displays →
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ interface FakeElement {
|
|||||||
textContent: string;
|
textContent: string;
|
||||||
classes: Set<string>;
|
classes: Set<string>;
|
||||||
attrs: Record<string, string>;
|
attrs: Record<string, string>;
|
||||||
classList: { toggle: (name: string, on: boolean) => void };
|
classList: { toggle: (name: string, on: boolean) => void; contains: (name: string) => boolean };
|
||||||
setAttribute: (name: string, value: string) => void;
|
setAttribute: (name: string, value: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,6 +55,9 @@ function fakeElement(): FakeElement {
|
|||||||
if (on) classes.add(name);
|
if (on) classes.add(name);
|
||||||
else classes.delete(name);
|
else classes.delete(name);
|
||||||
},
|
},
|
||||||
|
contains(name: string) {
|
||||||
|
return classes.has(name);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
setAttribute(name: string, value: string) {
|
setAttribute(name: string, value: string) {
|
||||||
attrs[name] = value;
|
attrs[name] = value;
|
||||||
@@ -92,10 +95,12 @@ function loadPanel(store: Map<string, string> | null) {
|
|||||||
vm.runInContext(panelsJs, context, { filename: 'panels-ui.js' });
|
vm.runInContext(panelsJs, context, { filename: 'panels-ui.js' });
|
||||||
|
|
||||||
const elements: Record<string, FakeElement> = {
|
const elements: Record<string, FakeElement> = {
|
||||||
|
fileBrowserPanel: fakeElement(),
|
||||||
fileBrowserTree: fakeElement(),
|
fileBrowserTree: fakeElement(),
|
||||||
fileBrowserStatus: fakeElement(),
|
fileBrowserStatus: fakeElement(),
|
||||||
fileBrowserHiddenBtn: fakeElement(),
|
fileBrowserHiddenBtn: fakeElement(),
|
||||||
};
|
};
|
||||||
|
elements.fileBrowserPanel.classList.toggle('visible', true);
|
||||||
const requests: string[] = [];
|
const requests: string[] = [];
|
||||||
const app = new CodemanApp() as Record<string, any>;
|
const app = new CodemanApp() as Record<string, any>;
|
||||||
app.$ = (id: string) => elements[id] ?? null;
|
app.$ = (id: string) => elements[id] ?? null;
|
||||||
@@ -149,10 +154,12 @@ describe('File Viewer show-hidden toggle', () => {
|
|||||||
const { app, requests } = loadPanel(store);
|
const { app, requests } = loadPanel(store);
|
||||||
await app.loadFileBrowser('sess-1');
|
await app.loadFileBrowser('sess-1');
|
||||||
expect(requests[0]).toContain('showHidden=false');
|
expect(requests[0]).toContain('showHidden=false');
|
||||||
|
const previousTreeEpoch = app._fileBrowserState.treeEpoch;
|
||||||
|
|
||||||
await app.toggleFileBrowserHidden();
|
await app.toggleFileBrowserHidden();
|
||||||
|
|
||||||
expect(app.fileBrowserShowHidden).toBe(true);
|
expect(app.fileBrowserShowHidden).toBe(true);
|
||||||
|
expect(app._fileBrowserState.treeEpoch).toBe(previousTreeEpoch + 1);
|
||||||
expect(requests).toHaveLength(2);
|
expect(requests).toHaveLength(2);
|
||||||
expect(requests[1]).toContain('showHidden=true');
|
expect(requests[1]).toContain('showHidden=true');
|
||||||
expect(store.get(STORAGE_KEY)).toBe('1');
|
expect(store.get(STORAGE_KEY)).toBe('1');
|
||||||
|
|||||||
@@ -207,6 +207,19 @@ function successfulData(overrides: Record<string, unknown> = {}) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function successfulTree(name: string, overrides: Record<string, unknown> = {}) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
tree: [{ name, path: name, type: 'file', size: 1, extension: 'ts' }],
|
||||||
|
totalFiles: 1,
|
||||||
|
totalDirectories: 0,
|
||||||
|
truncated: false,
|
||||||
|
...overrides,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
describe('File Viewer server search', () => {
|
describe('File Viewer server search', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
@@ -356,12 +369,6 @@ describe('File Viewer server search', () => {
|
|||||||
app._fileBrowserState.searchEpoch++;
|
app._fileBrowserState.searchEpoch++;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: 'tree epoch',
|
|
||||||
mutate: (app) => {
|
|
||||||
app._fileBrowserState.treeEpoch++;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: 'owner',
|
name: 'owner',
|
||||||
mutate: (app) => {
|
mutate: (app) => {
|
||||||
@@ -584,4 +591,269 @@ describe('File Viewer server search', () => {
|
|||||||
);
|
);
|
||||||
expect(html).not.toContain('<img src=x');
|
expect(html).not.toContain('<img src=x');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('normal tree loads', () => {
|
||||||
|
it('deduplicates compatible in-flight loads and reuses compatible ready and error states', async () => {
|
||||||
|
const ready = loadPanel();
|
||||||
|
ready.app._completeDeferredFileBrowserDirectory = vi.fn();
|
||||||
|
const first = ready.app.loadFileBrowser('session/A');
|
||||||
|
const duplicate = ready.app.loadFileBrowser('session/A');
|
||||||
|
|
||||||
|
expect(duplicate).toBe(first);
|
||||||
|
expect(ready.pending).toHaveLength(1);
|
||||||
|
ready.pending[0].reply.resolve(response(successfulTree('ready.ts')));
|
||||||
|
await first;
|
||||||
|
expect(ready.app._fileBrowserState.normalState.phase).toBe('ready');
|
||||||
|
expect(ready.elements.fileBrowserTree.innerHTML).toContain('ready.ts');
|
||||||
|
expect(ready.app._completeDeferredFileBrowserDirectory).toHaveBeenCalledWith(
|
||||||
|
ready.app._fileBrowserState.normalState
|
||||||
|
);
|
||||||
|
|
||||||
|
ready.elements.fileBrowserTree.innerHTML = '';
|
||||||
|
const reusedReady = ready.app.loadFileBrowser('session/A');
|
||||||
|
expect(ready.pending).toHaveLength(1);
|
||||||
|
expect(typeof reusedReady.then).toBe('function');
|
||||||
|
await reusedReady;
|
||||||
|
expect(ready.elements.fileBrowserTree.innerHTML).toContain('ready.ts');
|
||||||
|
|
||||||
|
const failed = loadPanel();
|
||||||
|
failed.app._completeDeferredFileBrowserDirectory = vi.fn();
|
||||||
|
const failedLoad = failed.app.loadFileBrowser('session/A');
|
||||||
|
failed.pending[0].reply.reject(new Error('<img src=x onerror=boom>'));
|
||||||
|
await failedLoad;
|
||||||
|
expect(failed.app._fileBrowserState.normalState.phase).toBe('error');
|
||||||
|
expect(failed.elements.fileBrowserTree.innerHTML).toContain('<img src=x onerror=boom>');
|
||||||
|
expect(failed.elements.fileBrowserTree.innerHTML).not.toContain('<img src=x');
|
||||||
|
expect(failed.elements.fileBrowserStatus.textContent).toContain('<img src=x onerror=boom>');
|
||||||
|
expect(failed.elements.fileBrowserStatus.innerHTML).toBe('');
|
||||||
|
expect(failed.app._completeDeferredFileBrowserDirectory).toHaveBeenCalledWith(
|
||||||
|
failed.app._fileBrowserState.normalState
|
||||||
|
);
|
||||||
|
|
||||||
|
failed.elements.fileBrowserTree.innerHTML = '';
|
||||||
|
await failed.app.loadFileBrowser('session/A');
|
||||||
|
expect(failed.pending).toHaveLength(1);
|
||||||
|
expect(failed.elements.fileBrowserTree.innerHTML).toContain('<img src=x onerror=boom>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refresh invalidates both work classes, clears navigation state, and forces an epoch-safe replacement', async () => {
|
||||||
|
const { app, elements, pending } = loadPanel();
|
||||||
|
const stale = app.loadFileBrowser('session/A');
|
||||||
|
app.filterFileBrowser('old query');
|
||||||
|
app.fileBrowserExpandedDirs.add('src');
|
||||||
|
app.fileBrowserAllExpanded = true;
|
||||||
|
app._fileBrowserState.deferredDirectoryTarget = { ownerSessionId: 'session/A', path: 'src' };
|
||||||
|
const beforeSearchEpoch = app._fileBrowserState.searchEpoch;
|
||||||
|
const beforeTreeEpoch = app._fileBrowserState.treeEpoch;
|
||||||
|
|
||||||
|
const replacement = app.refreshFileBrowser();
|
||||||
|
|
||||||
|
expect(app._fileBrowserState.searchEpoch).toBe(beforeSearchEpoch + 1);
|
||||||
|
expect(app._fileBrowserState.treeEpoch).toBe(beforeTreeEpoch + 1);
|
||||||
|
expect(app._fileBrowserState.filter).toBe('');
|
||||||
|
expect(app._fileBrowserState.matches).toEqual([]);
|
||||||
|
expect(app._fileBrowserState.deferredDirectoryTarget).toBeNull();
|
||||||
|
expect(app._fileBrowserState.view).toBe('normal');
|
||||||
|
expect(app.fileBrowserExpandedDirs.size).toBe(0);
|
||||||
|
expect(app.fileBrowserAllExpanded).toBe(false);
|
||||||
|
expect(elements.fileBrowserExpandBtn.disabled).toBe(false);
|
||||||
|
expect(elements.fileBrowserSearch.value).toBe('');
|
||||||
|
expect(elements.fileBrowserTree.innerHTML).toContain('Loading files');
|
||||||
|
expect(pending).toHaveLength(2);
|
||||||
|
|
||||||
|
pending[0].reply.resolve(response(successfulTree('stale.ts')));
|
||||||
|
await stale;
|
||||||
|
expect(elements.fileBrowserTree.innerHTML).not.toContain('stale.ts');
|
||||||
|
expect(app._fileBrowserState.treeInFlight.promise).toBe(replacement);
|
||||||
|
|
||||||
|
pending[1].reply.resolve(response(successfulTree('fresh.ts')));
|
||||||
|
await replacement;
|
||||||
|
expect(elements.fileBrowserTree.innerHTML).toContain('fresh.ts');
|
||||||
|
expect(elements.fileBrowserTree.innerHTML).not.toContain('stale.ts');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores stale pre-refresh failures while the replacement remains loading', async () => {
|
||||||
|
const { app, elements, pending } = loadPanel();
|
||||||
|
const stale = app.loadFileBrowser('session/A');
|
||||||
|
const replacement = app.refreshFileBrowser();
|
||||||
|
|
||||||
|
pending[0].reply.reject(new Error('stale failure'));
|
||||||
|
await stale;
|
||||||
|
expect(elements.fileBrowserTree.innerHTML).toContain('Loading files');
|
||||||
|
expect(elements.fileBrowserTree.innerHTML).not.toContain('stale failure');
|
||||||
|
|
||||||
|
pending[1].reply.resolve(response(successfulTree('replacement.ts')));
|
||||||
|
await replacement;
|
||||||
|
expect(elements.fileBrowserTree.innerHTML).toContain('replacement.ts');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('isolates overlapping owners and does not reuse an unresolved A request after A to B to A', async () => {
|
||||||
|
const { app, elements, pending } = loadPanel();
|
||||||
|
const firstA = app.loadFileBrowser('session/A');
|
||||||
|
app._fileBrowserState.ownerSessionId = 'session/B';
|
||||||
|
app.activeSessionId = 'session/B';
|
||||||
|
const loadB = app.loadFileBrowser('session/B');
|
||||||
|
|
||||||
|
app._fileBrowserState.ownerSessionId = 'session/A';
|
||||||
|
app.activeSessionId = 'session/A';
|
||||||
|
const secondA = app.loadFileBrowser('session/A');
|
||||||
|
expect(secondA).not.toBe(firstA);
|
||||||
|
expect(pending).toHaveLength(3);
|
||||||
|
|
||||||
|
pending[0].reply.resolve(response(successfulTree('stale-A.ts')));
|
||||||
|
await firstA;
|
||||||
|
expect(app.fileBrowserData).toBeNull();
|
||||||
|
expect(elements.fileBrowserTree.innerHTML).toContain('Loading files');
|
||||||
|
expect(elements.fileBrowserTree.innerHTML).not.toContain('stale-A.ts');
|
||||||
|
|
||||||
|
pending[1].reply.reject(new Error('late B failure'));
|
||||||
|
await loadB;
|
||||||
|
expect(elements.fileBrowserTree.innerHTML).not.toContain('late B failure');
|
||||||
|
pending[2].reply.resolve(response(successfulTree('current-A.ts')));
|
||||||
|
await secondA;
|
||||||
|
expect(elements.fileBrowserTree.innerHTML).toContain('current-A.ts');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows B loading after clearing search while B is unsettled, never cached A data', async () => {
|
||||||
|
const { app, elements, pending } = loadPanel();
|
||||||
|
const firstA = app.loadFileBrowser('session/A');
|
||||||
|
pending[0].reply.resolve(response(successfulTree('cached-A.ts')));
|
||||||
|
await firstA;
|
||||||
|
expect(elements.fileBrowserTree.innerHTML).toContain('cached-A.ts');
|
||||||
|
|
||||||
|
app._fileBrowserState.ownerSessionId = 'session/B';
|
||||||
|
app.activeSessionId = 'session/B';
|
||||||
|
const loadB = app.loadFileBrowser('session/B');
|
||||||
|
app.filterFileBrowser('temporary');
|
||||||
|
app.filterFileBrowser('');
|
||||||
|
|
||||||
|
expect(elements.fileBrowserTree.innerHTML).toContain('Loading files');
|
||||||
|
expect(elements.fileBrowserTree.innerHTML).not.toContain('cached-A.ts');
|
||||||
|
pending[1].reply.resolve(response(successfulTree('current-B.ts')));
|
||||||
|
await loadB;
|
||||||
|
expect(elements.fileBrowserTree.innerHTML).toContain('current-B.ts');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('settles a compatible tree behind search without repainting and keeps tree/search epochs independent', async () => {
|
||||||
|
const { app, elements, pending } = loadPanel();
|
||||||
|
const treeLoad = app.loadFileBrowser('session/A');
|
||||||
|
app.filterFileBrowser('query A');
|
||||||
|
await vi.advanceTimersByTimeAsync(250);
|
||||||
|
app.filterFileBrowser('query B');
|
||||||
|
await vi.advanceTimersByTimeAsync(250);
|
||||||
|
expect(pending).toHaveLength(3);
|
||||||
|
|
||||||
|
pending[0].reply.resolve(response(successfulTree('behind-search.ts')));
|
||||||
|
await treeLoad;
|
||||||
|
expect(app._fileBrowserState.normalState.phase).toBe('ready');
|
||||||
|
expect(app.fileBrowserData.tree[0].name).toBe('behind-search.ts');
|
||||||
|
expect(elements.fileBrowserTree.innerHTML).toContain('Searching');
|
||||||
|
expect(elements.fileBrowserTree.innerHTML).not.toContain('behind-search.ts');
|
||||||
|
|
||||||
|
app._fileBrowserState.treeEpoch++;
|
||||||
|
pending[2].reply.resolve(
|
||||||
|
response(successfulData({ matches: [{ name: 'query-B.ts', path: 'query-B.ts', type: 'file' }] }))
|
||||||
|
);
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
expect(elements.fileBrowserTree.innerHTML).toContain('query-B.ts');
|
||||||
|
|
||||||
|
pending[1].reply.resolve(
|
||||||
|
response(successfulData({ matches: [{ name: 'query-A.ts', path: 'query-A.ts', type: 'file' }] }))
|
||||||
|
);
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
expect(elements.fileBrowserTree.innerHTML).not.toContain('query-A.ts');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('suppresses invalidated tree responses and hidden-panel repainting', async () => {
|
||||||
|
const scenarios = [
|
||||||
|
{
|
||||||
|
name: 'owner',
|
||||||
|
mutate(app: Record<string, any>) {
|
||||||
|
app._fileBrowserState.ownerSessionId = 'session/B';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'tree epoch',
|
||||||
|
mutate(app: Record<string, any>) {
|
||||||
|
app._fileBrowserState.treeEpoch++;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'hidden preference',
|
||||||
|
mutate(app: Record<string, any>) {
|
||||||
|
app.fileBrowserShowHidden = true;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const scenario of scenarios) {
|
||||||
|
const { app, elements, pending } = loadPanel();
|
||||||
|
const load = app.loadFileBrowser('session/A');
|
||||||
|
scenario.mutate(app);
|
||||||
|
pending[0].reply.resolve(response(successfulTree(`late-${scenario.name}.ts`)));
|
||||||
|
await load;
|
||||||
|
expect(app.fileBrowserData, scenario.name).toBeNull();
|
||||||
|
expect(elements.fileBrowserTree.innerHTML, scenario.name).not.toContain(`late-${scenario.name}.ts`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hidden = loadPanel();
|
||||||
|
const hiddenLoad = hidden.app.loadFileBrowser('session/A');
|
||||||
|
hidden.elements.fileBrowserPanel.classList.remove('visible');
|
||||||
|
hidden.pending[0].reply.resolve(response(successfulTree('hidden-ready.ts')));
|
||||||
|
await hiddenLoad;
|
||||||
|
expect(hidden.app._fileBrowserState.normalState.phase).toBe('ready');
|
||||||
|
expect(hidden.elements.fileBrowserTree.innerHTML).toContain('Loading files');
|
||||||
|
expect(hidden.elements.fileBrowserTree.innerHTML).not.toContain('hidden-ready.ts');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('compare-and-clears only the exact stale request and preserves replacement deduplication', async () => {
|
||||||
|
const { app, pending } = loadPanel();
|
||||||
|
const stale = app.loadFileBrowser('session/A');
|
||||||
|
const replacement = app.loadFileBrowser('session/A', { force: true });
|
||||||
|
const replacementRecord = app._fileBrowserState.treeInFlight;
|
||||||
|
|
||||||
|
pending[0].reply.resolve(response(successfulTree('stale.ts')));
|
||||||
|
await stale;
|
||||||
|
expect(app._fileBrowserState.treeInFlight).toBe(replacementRecord);
|
||||||
|
|
||||||
|
const reused = app.loadFileBrowser('session/A');
|
||||||
|
expect(reused).toBe(replacement);
|
||||||
|
expect(pending).toHaveLength(2);
|
||||||
|
|
||||||
|
pending[1].reply.resolve(response(successfulTree('replacement.ts')));
|
||||||
|
await replacement;
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['tree is not an array', successfulTree('x.ts', { tree: {} })],
|
||||||
|
['totalFiles is not finite', successfulTree('x.ts', { totalFiles: Number.POSITIVE_INFINITY })],
|
||||||
|
['the envelope is search-discriminated', successfulTree('x.ts', { mode: 'search', matches: [], matchCount: 0 })],
|
||||||
|
[
|
||||||
|
'a nested node is malformed',
|
||||||
|
successfulTree('x.ts', { tree: [{ name: 'dir', path: 'dir', type: 'directory', children: [{}] }] }),
|
||||||
|
],
|
||||||
|
])('stores an escaped error instead of malformed tree data when %s', async (_case, body) => {
|
||||||
|
const { app, elements, pending } = loadPanel();
|
||||||
|
const load = app.loadFileBrowser('session/A');
|
||||||
|
pending[0].reply.resolve(response(body));
|
||||||
|
await load;
|
||||||
|
|
||||||
|
expect(app.fileBrowserData).toBeNull();
|
||||||
|
expect(app._fileBrowserState.normalState.phase).toBe('error');
|
||||||
|
expect(elements.fileBrowserTree.innerHTML).toContain('Failed to load files');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not adopt a different owner or fetch without a usable tree surface', () => {
|
||||||
|
const wrongOwner = loadPanel();
|
||||||
|
wrongOwner.app._ensureFileBrowserState().ownerSessionId = 'session/B';
|
||||||
|
expect(wrongOwner.app.loadFileBrowser('session/A')).toBeUndefined();
|
||||||
|
expect(wrongOwner.pending).toHaveLength(0);
|
||||||
|
expect(wrongOwner.app._fileBrowserState.ownerSessionId).toBe('session/B');
|
||||||
|
|
||||||
|
const missingTree = loadPanel();
|
||||||
|
delete missingTree.elements.fileBrowserTree;
|
||||||
|
expect(missingTree.app.loadFileBrowser('session/A')).toBeUndefined();
|
||||||
|
expect(missingTree.pending).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user