mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-08 08:29:42 +02:00
fix(file-viewer): COD-341 complete search transitions
This commit is contained in:
+217
-9
@@ -3026,7 +3026,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
|
|
||||||
const searchInput = this.$?.('fileBrowserSearch');
|
const searchInput = this.$?.('fileBrowserSearch');
|
||||||
if (searchInput) searchInput.value = '';
|
if (searchInput) searchInput.value = '';
|
||||||
this._setFileBrowserExpandDisabled(false);
|
this._syncFileBrowserExpandBtn();
|
||||||
const expandBtn = this.$?.('fileBrowserExpandBtn');
|
const expandBtn = this.$?.('fileBrowserExpandBtn');
|
||||||
if (expandBtn) expandBtn.innerHTML = '\u229E';
|
if (expandBtn) expandBtn.innerHTML = '\u229E';
|
||||||
|
|
||||||
@@ -3068,7 +3068,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
|
|
||||||
const searchInput = this.$?.('fileBrowserSearch');
|
const searchInput = this.$?.('fileBrowserSearch');
|
||||||
if (searchInput) searchInput.value = '';
|
if (searchInput) searchInput.value = '';
|
||||||
this._setFileBrowserExpandDisabled(false);
|
this._syncFileBrowserExpandBtn();
|
||||||
const expandBtn = this.$?.('fileBrowserExpandBtn');
|
const expandBtn = this.$?.('fileBrowserExpandBtn');
|
||||||
if (expandBtn) expandBtn.innerHTML = '\u229E';
|
if (expandBtn) expandBtn.innerHTML = '\u229E';
|
||||||
const treeEl = this.$?.('fileBrowserTree');
|
const treeEl = this.$?.('fileBrowserTree');
|
||||||
@@ -3082,6 +3082,18 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
if (btn) btn.disabled = disabled;
|
if (btn) btn.disabled = disabled;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
_hasFileBrowserQuery() {
|
||||||
|
const state = this._ensureFileBrowserState();
|
||||||
|
const input = this.$?.('fileBrowserSearch');
|
||||||
|
const inputValue = typeof input?.value === 'string' ? input.value : '';
|
||||||
|
const filterValue = typeof state.filter === 'string' ? state.filter : '';
|
||||||
|
return inputValue.trim() !== '' || filterValue.trim() !== '';
|
||||||
|
},
|
||||||
|
|
||||||
|
_syncFileBrowserExpandBtn() {
|
||||||
|
this._setFileBrowserExpandDisabled(this._hasFileBrowserQuery());
|
||||||
|
},
|
||||||
|
|
||||||
_renderFileBrowserNormalStatus(data, showHidden) {
|
_renderFileBrowserNormalStatus(data, showHidden) {
|
||||||
const statusEl = this.$('fileBrowserStatus');
|
const statusEl = this.$('fileBrowserStatus');
|
||||||
if (!statusEl || !data) return;
|
if (!statusEl || !data) return;
|
||||||
@@ -3148,7 +3160,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
|
|
||||||
if (normalState.phase !== 'ready') return;
|
if (normalState.phase !== 'ready') return;
|
||||||
this.fileBrowserData = normalState.data;
|
this.fileBrowserData = normalState.data;
|
||||||
this._setFileBrowserExpandDisabled(false);
|
this._syncFileBrowserExpandBtn();
|
||||||
this.renderFileBrowserTree(normalState.sessionId);
|
this.renderFileBrowserTree(normalState.sessionId);
|
||||||
this._renderFileBrowserNormalStatus(normalState.data, normalState.showHidden);
|
this._renderFileBrowserNormalStatus(normalState.data, normalState.showHidden);
|
||||||
},
|
},
|
||||||
@@ -3229,15 +3241,70 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
if (statusEl) statusEl.textContent = message;
|
if (statusEl) statusEl.textContent = message;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
_canContinueFileBrowserHiddenReload(continuation, normalState) {
|
||||||
|
const state = this._ensureFileBrowserState();
|
||||||
|
const input = this.$?.('fileBrowserSearch');
|
||||||
|
const currentInput = typeof input?.value === 'string' ? input.value : state.filter;
|
||||||
|
return (
|
||||||
|
state.view === 'normal' &&
|
||||||
|
state.searchEpoch === continuation.searchEpoch &&
|
||||||
|
state.treeEpoch === continuation.treeEpoch &&
|
||||||
|
state.ownerSessionId === continuation.ownerSessionId &&
|
||||||
|
this.activeSessionId === continuation.ownerSessionId &&
|
||||||
|
(this.fileBrowserShowHidden === true) === continuation.showHidden &&
|
||||||
|
state.filter === continuation.rawInput &&
|
||||||
|
currentInput === continuation.rawInput &&
|
||||||
|
currentInput.trim() === continuation.query &&
|
||||||
|
this.$?.('fileBrowserPanel')?.classList.contains('visible') === true &&
|
||||||
|
normalState?.phase === 'ready' &&
|
||||||
|
this._isFileBrowserNormalCompatible(
|
||||||
|
normalState,
|
||||||
|
continuation.ownerSessionId,
|
||||||
|
continuation.showHidden,
|
||||||
|
continuation.treeEpoch,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
async toggleFileBrowserHidden() {
|
async toggleFileBrowserHidden() {
|
||||||
|
const state = this._ensureFileBrowserState();
|
||||||
|
const rawInput = typeof state.filter === 'string' ? state.filter : '';
|
||||||
|
const query = rawInput.trim();
|
||||||
this.fileBrowserShowHidden = !this.fileBrowserShowHidden;
|
this.fileBrowserShowHidden = !this.fileBrowserShowHidden;
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(FILE_BROWSER_SHOW_HIDDEN_KEY, this.fileBrowserShowHidden ? '1' : '0');
|
localStorage.setItem(FILE_BROWSER_SHOW_HIDDEN_KEY, this.fileBrowserShowHidden ? '1' : '0');
|
||||||
} catch {}
|
} catch {}
|
||||||
this._syncFileBrowserHiddenBtn();
|
this._syncFileBrowserHiddenBtn();
|
||||||
|
|
||||||
|
if (state.inFlight?.timer !== undefined && state.inFlight?.timer !== null) {
|
||||||
|
clearTimeout(state.inFlight.timer);
|
||||||
|
}
|
||||||
|
state.searchEpoch++;
|
||||||
|
state.inFlight = null;
|
||||||
|
state.matches = [];
|
||||||
|
state.deferredDirectoryTarget = null;
|
||||||
|
state.normalState = null;
|
||||||
|
this.fileBrowserData = null;
|
||||||
|
if (query.length <= 256) state.view = 'normal';
|
||||||
|
this._syncFileBrowserExpandBtn();
|
||||||
|
|
||||||
// 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, { force: true });
|
const ownerSessionId = state.ownerSessionId || this.activeSessionId;
|
||||||
|
if (!ownerSessionId || this.activeSessionId !== ownerSessionId) return;
|
||||||
|
|
||||||
|
const searchEpoch = state.searchEpoch;
|
||||||
|
const showHidden = this.fileBrowserShowHidden === true;
|
||||||
|
const load = this.loadFileBrowser(ownerSessionId, { force: true });
|
||||||
|
const treeEpoch = state.treeEpoch;
|
||||||
|
if (!load?.then) return;
|
||||||
|
await load;
|
||||||
|
|
||||||
|
if (!query || query.length > 256) return;
|
||||||
|
const continuation = { ownerSessionId, showHidden, treeEpoch, searchEpoch, rawInput, query };
|
||||||
|
if (this._canContinueFileBrowserHiddenReload(continuation, state.normalState)) {
|
||||||
|
this.filterFileBrowser(rawInput);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
loadFileBrowser(sessionId, { force = false } = {}) {
|
loadFileBrowser(sessionId, { force = false } = {}) {
|
||||||
@@ -3416,6 +3483,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
state.filter = rawInput;
|
state.filter = rawInput;
|
||||||
state.deferredDirectoryTarget = null;
|
state.deferredDirectoryTarget = null;
|
||||||
this.fileBrowserFilter = rawInput;
|
this.fileBrowserFilter = rawInput;
|
||||||
|
this._syncFileBrowserExpandBtn();
|
||||||
|
|
||||||
if (state.inFlight?.timer !== undefined && state.inFlight?.timer !== null) {
|
if (state.inFlight?.timer !== undefined && state.inFlight?.timer !== null) {
|
||||||
clearTimeout(state.inFlight.timer);
|
clearTimeout(state.inFlight.timer);
|
||||||
@@ -3428,7 +3496,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
if (!query) {
|
if (!query) {
|
||||||
state.view = 'normal';
|
state.view = 'normal';
|
||||||
state.matches = [];
|
state.matches = [];
|
||||||
this._setFileBrowserExpandDisabled(false);
|
this._syncFileBrowserExpandBtn();
|
||||||
const normal = state.normalState;
|
const normal = state.normalState;
|
||||||
if (
|
if (
|
||||||
ownerSessionId &&
|
ownerSessionId &&
|
||||||
@@ -3448,7 +3516,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
const message = 'Search queries are limited to 256 characters';
|
const message = 'Search queries are limited to 256 characters';
|
||||||
state.view = 'query-error';
|
state.view = 'query-error';
|
||||||
state.matches = [];
|
state.matches = [];
|
||||||
this._setFileBrowserExpandDisabled(true);
|
this._syncFileBrowserExpandBtn();
|
||||||
const treeEl = this.$('fileBrowserTree');
|
const treeEl = this.$('fileBrowserTree');
|
||||||
const statusEl = this.$('fileBrowserStatus');
|
const statusEl = this.$('fileBrowserStatus');
|
||||||
if (treeEl) treeEl.innerHTML = `<div class="file-browser-empty">${escapeHtml(message)}</div>`;
|
if (treeEl) treeEl.innerHTML = `<div class="file-browser-empty">${escapeHtml(message)}</div>`;
|
||||||
@@ -3472,7 +3540,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
state.view = 'search-pending';
|
state.view = 'search-pending';
|
||||||
state.matches = [];
|
state.matches = [];
|
||||||
state.inFlight = request;
|
state.inFlight = request;
|
||||||
this._setFileBrowserExpandDisabled(true);
|
this._syncFileBrowserExpandBtn();
|
||||||
treeEl.innerHTML = `<div class="file-browser-loading">${escapeHtml('Searching...')}</div>`;
|
treeEl.innerHTML = `<div class="file-browser-loading">${escapeHtml('Searching...')}</div>`;
|
||||||
const statusEl = this.$('fileBrowserStatus');
|
const statusEl = this.$('fileBrowserStatus');
|
||||||
if (statusEl) statusEl.textContent = 'Searching...';
|
if (statusEl) statusEl.textContent = 'Searching...';
|
||||||
@@ -3508,6 +3576,16 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
_renderFileBrowserSearchResults(matches, ownerSessionId, data) {
|
_renderFileBrowserSearchResults(matches, ownerSessionId, data) {
|
||||||
const treeEl = this.$('fileBrowserTree');
|
const treeEl = this.$('fileBrowserTree');
|
||||||
if (!treeEl || !ownerSessionId) return;
|
if (!treeEl || !ownerSessionId) return;
|
||||||
|
const state = this._ensureFileBrowserState();
|
||||||
|
const searchContext = {
|
||||||
|
ownerSessionId,
|
||||||
|
showHidden: this.fileBrowserShowHidden === true,
|
||||||
|
treeEpoch: state.treeEpoch,
|
||||||
|
searchEpoch: state.searchEpoch,
|
||||||
|
rawInput: state.filter,
|
||||||
|
query: state.filter.trim(),
|
||||||
|
view: state.view,
|
||||||
|
};
|
||||||
if (matches.length === 0) {
|
if (matches.length === 0) {
|
||||||
treeEl.innerHTML = `<div class="file-browser-empty">${escapeHtml('No matches')}</div>`;
|
treeEl.innerHTML = `<div class="file-browser-empty">${escapeHtml('No matches')}</div>`;
|
||||||
} else {
|
} else {
|
||||||
@@ -3540,7 +3618,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
item.addEventListener('click', () => {
|
item.addEventListener('click', () => {
|
||||||
const path = item.dataset.path;
|
const path = item.dataset.path;
|
||||||
if (item.dataset.type === 'directory') {
|
if (item.dataset.type === 'directory') {
|
||||||
this._ensureFileBrowserState().deferredDirectoryTarget = { ownerSessionId, path };
|
this._openFileBrowserSearchDirectory({ ...searchContext, path });
|
||||||
} else {
|
} else {
|
||||||
this.openFilePreview(path, ownerSessionId);
|
this.openFilePreview(path, ownerSessionId);
|
||||||
}
|
}
|
||||||
@@ -3554,6 +3632,132 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
_findFileBrowserDirectory(nodes, targetPath, ancestors = []) {
|
||||||
|
if (!Array.isArray(nodes)) return null;
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (!node || typeof node !== 'object') continue;
|
||||||
|
if (node.type === 'directory' && node.path === targetPath) {
|
||||||
|
return { target: node, ancestors: [...ancestors] };
|
||||||
|
}
|
||||||
|
if (node.type !== 'directory' || !Array.isArray(node.children)) continue;
|
||||||
|
const found = this._findFileBrowserDirectory(node.children, targetPath, [...ancestors, node.path]);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
|
||||||
|
_isFileBrowserDirectoryContextCurrent(target) {
|
||||||
|
const state = this._ensureFileBrowserState();
|
||||||
|
const input = this.$?.('fileBrowserSearch');
|
||||||
|
const currentInput = typeof input?.value === 'string' ? input.value : state.filter;
|
||||||
|
return (
|
||||||
|
target &&
|
||||||
|
state.ownerSessionId === target.ownerSessionId &&
|
||||||
|
this.activeSessionId === target.ownerSessionId &&
|
||||||
|
state.treeEpoch === target.treeEpoch &&
|
||||||
|
state.searchEpoch === target.searchEpoch &&
|
||||||
|
(this.fileBrowserShowHidden === true) === target.showHidden &&
|
||||||
|
state.filter === target.rawInput &&
|
||||||
|
currentInput === target.rawInput &&
|
||||||
|
currentInput.trim() === target.query &&
|
||||||
|
state.view === target.view &&
|
||||||
|
this.$?.('fileBrowserPanel')?.classList.contains('visible') === true
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
_promptFileBrowserDirectoryReload() {
|
||||||
|
this.showToast?.('Reload files before opening this folder', 'info');
|
||||||
|
},
|
||||||
|
|
||||||
|
_openFileBrowserSearchDirectory(target) {
|
||||||
|
if (!this._isFileBrowserDirectoryContextCurrent(target)) return;
|
||||||
|
const state = this._ensureFileBrowserState();
|
||||||
|
const normalState = state.normalState;
|
||||||
|
if (
|
||||||
|
!this._isFileBrowserNormalCompatible(
|
||||||
|
normalState,
|
||||||
|
target.ownerSessionId,
|
||||||
|
target.showHidden,
|
||||||
|
target.treeEpoch,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
state.deferredDirectoryTarget = null;
|
||||||
|
this._promptFileBrowserDirectoryReload();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalState.phase === 'loading') {
|
||||||
|
state.deferredDirectoryTarget = { ...target };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.deferredDirectoryTarget = null;
|
||||||
|
if (normalState.phase !== 'ready') {
|
||||||
|
this._promptFileBrowserDirectoryReload();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const found = this._findFileBrowserDirectory(normalState.data?.tree, target.path);
|
||||||
|
if (!found) {
|
||||||
|
this._promptFileBrowserDirectoryReload();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._leaveFileBrowserSearchForDirectory([...found.ancestors, found.target.path], normalState);
|
||||||
|
},
|
||||||
|
|
||||||
|
_completeDeferredFileBrowserDirectory(normalState) {
|
||||||
|
const state = this._ensureFileBrowserState();
|
||||||
|
const target = state.deferredDirectoryTarget;
|
||||||
|
if (!target || !this._isFileBrowserDirectoryContextCurrent(target)) return;
|
||||||
|
if (
|
||||||
|
!this._isFileBrowserNormalCompatible(
|
||||||
|
normalState,
|
||||||
|
target.ownerSessionId,
|
||||||
|
target.showHidden,
|
||||||
|
target.treeEpoch,
|
||||||
|
) ||
|
||||||
|
(normalState.phase !== 'ready' && normalState.phase !== 'error')
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.deferredDirectoryTarget = null;
|
||||||
|
if (normalState.phase === 'error') {
|
||||||
|
this._promptFileBrowserDirectoryReload();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const found = this._findFileBrowserDirectory(normalState.data?.tree, target.path);
|
||||||
|
if (!found) {
|
||||||
|
this._promptFileBrowserDirectoryReload();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._leaveFileBrowserSearchForDirectory([...found.ancestors, found.target.path], normalState);
|
||||||
|
},
|
||||||
|
|
||||||
|
_leaveFileBrowserSearchForDirectory(paths, normalState) {
|
||||||
|
const state = this._ensureFileBrowserState();
|
||||||
|
if (state.inFlight?.timer !== undefined && state.inFlight?.timer !== null) {
|
||||||
|
clearTimeout(state.inFlight.timer);
|
||||||
|
}
|
||||||
|
state.searchEpoch++;
|
||||||
|
state.inFlight = null;
|
||||||
|
state.filter = '';
|
||||||
|
state.matches = [];
|
||||||
|
state.deferredDirectoryTarget = null;
|
||||||
|
state.view = 'normal';
|
||||||
|
this.fileBrowserFilter = '';
|
||||||
|
|
||||||
|
const input = this.$?.('fileBrowserSearch');
|
||||||
|
if (input) input.value = '';
|
||||||
|
this._syncFileBrowserExpandBtn();
|
||||||
|
for (const path of paths) {
|
||||||
|
if (typeof path === 'string') this.fileBrowserExpandedDirs?.add?.(path);
|
||||||
|
}
|
||||||
|
this.fileBrowserData = normalState.data;
|
||||||
|
this._renderFileBrowserNormalState(normalState);
|
||||||
|
},
|
||||||
|
|
||||||
expandAllDirectories(nodes) {
|
expandAllDirectories(nodes) {
|
||||||
for (const node of nodes) {
|
for (const node of nodes) {
|
||||||
if (node.type === 'directory') {
|
if (node.type === 'directory') {
|
||||||
@@ -3570,6 +3774,10 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
},
|
},
|
||||||
|
|
||||||
toggleFileBrowserExpand() {
|
toggleFileBrowserExpand() {
|
||||||
|
if (this._hasFileBrowserQuery()) {
|
||||||
|
this._syncFileBrowserExpandBtn();
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.fileBrowserAllExpanded = !this.fileBrowserAllExpanded;
|
this.fileBrowserAllExpanded = !this.fileBrowserAllExpanded;
|
||||||
const btn = this.$('fileBrowserExpandBtn');
|
const btn = this.$('fileBrowserExpandBtn');
|
||||||
|
|
||||||
@@ -3597,11 +3805,11 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
this.fileBrowserFilter = '';
|
this.fileBrowserFilter = '';
|
||||||
this.fileBrowserExpandedDirs.clear();
|
this.fileBrowserExpandedDirs.clear();
|
||||||
this.fileBrowserAllExpanded = false;
|
this.fileBrowserAllExpanded = false;
|
||||||
this._setFileBrowserExpandDisabled(false);
|
|
||||||
const expandBtn = this.$('fileBrowserExpandBtn');
|
const expandBtn = this.$('fileBrowserExpandBtn');
|
||||||
if (expandBtn) expandBtn.innerHTML = '\u229E';
|
if (expandBtn) expandBtn.innerHTML = '\u229E';
|
||||||
const searchInput = this.$('fileBrowserSearch');
|
const searchInput = this.$('fileBrowserSearch');
|
||||||
if (searchInput) searchInput.value = '';
|
if (searchInput) searchInput.value = '';
|
||||||
|
this._syncFileBrowserExpandBtn();
|
||||||
|
|
||||||
const ownerSessionId = state.ownerSessionId || this.activeSessionId;
|
const ownerSessionId = state.ownerSessionId || this.activeSessionId;
|
||||||
if (!ownerSessionId || this.activeSessionId !== ownerSessionId) return undefined;
|
if (!ownerSessionId || this.activeSessionId !== ownerSessionId) return undefined;
|
||||||
|
|||||||
@@ -189,6 +189,7 @@ function loadPanel(options: { sessionId?: string | null; showHidden?: boolean }
|
|||||||
app.fileBrowserAllExpanded = false;
|
app.fileBrowserAllExpanded = false;
|
||||||
app.fileBrowserShowHidden = options.showHidden ?? false;
|
app.fileBrowserShowHidden = options.showHidden ?? false;
|
||||||
app.openFilePreview = vi.fn();
|
app.openFilePreview = vi.fn();
|
||||||
|
app.showToast = vi.fn();
|
||||||
app.loadAppSettingsFromStorage = () => ({ showFileBrowser: false });
|
app.loadAppSettingsFromStorage = () => ({ showFileBrowser: false });
|
||||||
app.getDefaultSettings = () => ({});
|
app.getDefaultSettings = () => ({});
|
||||||
app.saveAppSettingsToStorage = vi.fn();
|
app.saveAppSettingsToStorage = vi.fn();
|
||||||
@@ -317,6 +318,8 @@ function loadRealSelectSessionHarness(options: { terminalFailure?: boolean } = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function startSearch(app: Record<string, any>, query = 'widget') {
|
async function startSearch(app: Record<string, any>, query = 'widget') {
|
||||||
|
const input = app.$('fileBrowserSearch');
|
||||||
|
if (input) input.value = query;
|
||||||
app.filterFileBrowser(query);
|
app.filterFileBrowser(query);
|
||||||
await vi.advanceTimersByTimeAsync(250);
|
await vi.advanceTimersByTimeAsync(250);
|
||||||
}
|
}
|
||||||
@@ -471,14 +474,21 @@ describe('File Viewer server search', () => {
|
|||||||
expect(elements.fileBrowserStatus.textContent).toBe('2 matches (truncated)');
|
expect(elements.fileBrowserStatus.textContent).toBe('2 matches (truncated)');
|
||||||
expect(elements.fileBrowserExpandBtn.disabled).toBe(true);
|
expect(elements.fileBrowserExpandBtn.disabled).toBe(true);
|
||||||
|
|
||||||
|
app._openFileBrowserSearchDirectory = vi.fn();
|
||||||
app.activeSessionId = 'later-session';
|
app.activeSessionId = 'later-session';
|
||||||
const rows = elements.fileBrowserTree.querySelectorAll('.file-tree-item');
|
const rows = elements.fileBrowserTree.querySelectorAll('.file-tree-item');
|
||||||
rows[0].click();
|
rows[0].click();
|
||||||
rows[1].click();
|
rows[1].click();
|
||||||
expect(app.openFilePreview).toHaveBeenCalledWith('src/widget.ts', 'session/A');
|
expect(app.openFilePreview).toHaveBeenCalledWith('src/widget.ts', 'session/A');
|
||||||
expect(app._fileBrowserState.deferredDirectoryTarget).toEqual({
|
expect(app._openFileBrowserSearchDirectory).toHaveBeenCalledWith({
|
||||||
ownerSessionId: 'session/A',
|
ownerSessionId: 'session/A',
|
||||||
|
showHidden: false,
|
||||||
|
treeEpoch: 0,
|
||||||
|
searchEpoch: 1,
|
||||||
|
rawInput: 'widget',
|
||||||
|
query: 'widget',
|
||||||
path: 'docs/widgets',
|
path: 'docs/widgets',
|
||||||
|
view: 'search-results',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -730,6 +740,594 @@ describe('File Viewer server search', () => {
|
|||||||
expect(html).not.toContain('<img src=x');
|
expect(html).not.toContain('<img src=x');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('hidden-preference transitions', () => {
|
||||||
|
it('owns normal loading immediately and reruns an exact valid query only after the replacement tree succeeds', async () => {
|
||||||
|
const { app, elements, pending } = loadPanel();
|
||||||
|
app.fileBrowserData = successfulTree('old-preference.ts').data;
|
||||||
|
await settleSearch(
|
||||||
|
app,
|
||||||
|
pending,
|
||||||
|
successfulData({
|
||||||
|
matches: [{ name: 'old-result.ts', path: 'old-result.ts', type: 'file' }],
|
||||||
|
matchCount: 1,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
elements.fileBrowserSearch.value = ' widget ';
|
||||||
|
app._fileBrowserState.filter = ' widget ';
|
||||||
|
app.fileBrowserFilter = ' widget ';
|
||||||
|
const beforeSearchEpoch = app._fileBrowserState.searchEpoch;
|
||||||
|
const beforeTreeEpoch = app._fileBrowserState.treeEpoch;
|
||||||
|
|
||||||
|
const toggle = app.toggleFileBrowserHidden();
|
||||||
|
|
||||||
|
expect(app.fileBrowserShowHidden).toBe(true);
|
||||||
|
expect(elements.fileBrowserHiddenBtn.attrs['aria-pressed']).toBe('true');
|
||||||
|
expect(app._fileBrowserState.searchEpoch).toBe(beforeSearchEpoch + 1);
|
||||||
|
expect(app._fileBrowserState.treeEpoch).toBe(beforeTreeEpoch + 1);
|
||||||
|
expect(app._fileBrowserState.view).toBe('normal');
|
||||||
|
expect(app._fileBrowserState.matches).toEqual([]);
|
||||||
|
expect(app._fileBrowserState.normalState).toMatchObject({
|
||||||
|
sessionId: 'session/A',
|
||||||
|
showHidden: true,
|
||||||
|
treeEpoch: beforeTreeEpoch + 1,
|
||||||
|
phase: 'loading',
|
||||||
|
});
|
||||||
|
expect(app.fileBrowserData).toBeNull();
|
||||||
|
expect(elements.fileBrowserSearch.value).toBe(' widget ');
|
||||||
|
expect(elements.fileBrowserExpandBtn.disabled).toBe(true);
|
||||||
|
expect(elements.fileBrowserTree.innerHTML).toContain('Loading files');
|
||||||
|
expect(elements.fileBrowserTree.innerHTML).not.toContain('old-result.ts');
|
||||||
|
expect(elements.fileBrowserTree.innerHTML).not.toContain('old-preference.ts');
|
||||||
|
expect(pending[1].url).toBe('/api/sessions/session%2FA/files?depth=5&showHidden=true');
|
||||||
|
|
||||||
|
pending[1].reply.resolve(response(successfulTree('new-preference.ts')));
|
||||||
|
await toggle;
|
||||||
|
|
||||||
|
expect(app._fileBrowserState.view).toBe('search-pending');
|
||||||
|
expect(elements.fileBrowserTree.innerHTML).toContain('Searching');
|
||||||
|
await vi.advanceTimersByTimeAsync(250);
|
||||||
|
expect(pending[2].url).toBe('/api/sessions/session%2FA/files?depth=5&showHidden=true&q=widget');
|
||||||
|
pending[2].reply.resolve(
|
||||||
|
response(
|
||||||
|
successfulData({
|
||||||
|
matches: [{ name: 'new-result.ts', path: 'new-result.ts', type: 'file' }],
|
||||||
|
matchCount: 1,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
expect(elements.fileBrowserTree.innerHTML).toContain('new-result.ts');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets input during the reload own the UI and invalidates the captured continuation', async () => {
|
||||||
|
const { app, elements, pending } = loadPanel();
|
||||||
|
await settleSearch(
|
||||||
|
app,
|
||||||
|
pending,
|
||||||
|
successfulData({ matches: [{ name: 'old.ts', path: 'old.ts', type: 'file' }], matchCount: 1 })
|
||||||
|
);
|
||||||
|
|
||||||
|
const toggle = app.toggleFileBrowserHidden();
|
||||||
|
await startSearch(app, 'new query');
|
||||||
|
expect(pending[2].url).toContain('showHidden=true&q=new%20query');
|
||||||
|
pending[2].reply.resolve(
|
||||||
|
response(
|
||||||
|
successfulData({ matches: [{ name: 'new-query.ts', path: 'new-query.ts', type: 'file' }], matchCount: 1 })
|
||||||
|
)
|
||||||
|
);
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
expect(elements.fileBrowserTree.innerHTML).toContain('new-query.ts');
|
||||||
|
|
||||||
|
pending[1].reply.resolve(response(successfulTree('behind-new-query.ts')));
|
||||||
|
await toggle;
|
||||||
|
|
||||||
|
expect(app._fileBrowserState.normalState.phase).toBe('ready');
|
||||||
|
expect(elements.fileBrowserTree.innerHTML).toContain('new-query.ts');
|
||||||
|
expect(elements.fileBrowserTree.innerHTML).not.toContain('behind-new-query.ts');
|
||||||
|
await vi.advanceTimersByTimeAsync(250);
|
||||||
|
expect(pending).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['tree epoch', (app: Record<string, any>) => app._fileBrowserState.treeEpoch++],
|
||||||
|
['search epoch', (app: Record<string, any>) => app._fileBrowserState.searchEpoch++],
|
||||||
|
['owner', (app: Record<string, any>) => (app._fileBrowserState.ownerSessionId = 'session/B')],
|
||||||
|
['active session', (app: Record<string, any>) => (app.activeSessionId = 'session/B')],
|
||||||
|
['hidden preference', (app: Record<string, any>) => (app.fileBrowserShowHidden = false)],
|
||||||
|
['raw filter', (app: Record<string, any>) => (app._fileBrowserState.filter = 'changed')],
|
||||||
|
[
|
||||||
|
'raw input',
|
||||||
|
(_app: Record<string, any>, elements: Record<string, FakeElement>) =>
|
||||||
|
(elements.fileBrowserSearch.value = 'changed'),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'panel visibility',
|
||||||
|
(_app: Record<string, any>, elements: Record<string, FakeElement>) =>
|
||||||
|
elements.fileBrowserPanel.classList.remove('visible'),
|
||||||
|
],
|
||||||
|
])('does not rerun a captured query after its %s context changes', async (_case, mutate) => {
|
||||||
|
const { app, elements, pending } = loadPanel();
|
||||||
|
await settleSearch(app, pending, successfulData());
|
||||||
|
const toggle = app.toggleFileBrowserHidden();
|
||||||
|
|
||||||
|
mutate(app, elements);
|
||||||
|
pending[1].reply.resolve(response(successfulTree('replacement.ts')));
|
||||||
|
await toggle;
|
||||||
|
await vi.advanceTimersByTimeAsync(250);
|
||||||
|
|
||||||
|
expect(pending).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('invalidates the first load and continuation when two toggles happen rapidly', async () => {
|
||||||
|
const { app, elements, pending } = loadPanel();
|
||||||
|
await settleSearch(
|
||||||
|
app,
|
||||||
|
pending,
|
||||||
|
successfulData({ matches: [{ name: 'old.ts', path: 'old.ts', type: 'file' }], matchCount: 1 })
|
||||||
|
);
|
||||||
|
|
||||||
|
const firstToggle = app.toggleFileBrowserHidden();
|
||||||
|
const secondToggle = app.toggleFileBrowserHidden();
|
||||||
|
expect(pending.slice(1).map(({ url }) => url)).toEqual([
|
||||||
|
'/api/sessions/session%2FA/files?depth=5&showHidden=true',
|
||||||
|
'/api/sessions/session%2FA/files?depth=5&showHidden=false',
|
||||||
|
]);
|
||||||
|
|
||||||
|
pending[1].reply.resolve(response(successfulTree('stale-hidden.ts')));
|
||||||
|
await firstToggle;
|
||||||
|
expect(elements.fileBrowserTree.innerHTML).toContain('Loading files');
|
||||||
|
expect(elements.fileBrowserTree.innerHTML).not.toContain('stale-hidden.ts');
|
||||||
|
|
||||||
|
pending[2].reply.resolve(response(successfulTree('fresh-visible.ts')));
|
||||||
|
await secondToggle;
|
||||||
|
await vi.advanceTimersByTimeAsync(250);
|
||||||
|
|
||||||
|
expect(pending).toHaveLength(4);
|
||||||
|
expect(pending[3].url).toBe('/api/sessions/session%2FA/files?depth=5&showHidden=false&q=widget');
|
||||||
|
expect(app.fileBrowserShowHidden).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores and escapes a tree failure, keeps the new preference, and exposes the error on clear', async () => {
|
||||||
|
const { app, elements, pending } = loadPanel();
|
||||||
|
await settleSearch(
|
||||||
|
app,
|
||||||
|
pending,
|
||||||
|
successfulData({ matches: [{ name: 'old.ts', path: 'old.ts', type: 'file' }], matchCount: 1 })
|
||||||
|
);
|
||||||
|
|
||||||
|
const toggle = app.toggleFileBrowserHidden();
|
||||||
|
pending[1].reply.reject(new Error('<img src=x onerror=boom>'));
|
||||||
|
await toggle;
|
||||||
|
|
||||||
|
expect(app.fileBrowserShowHidden).toBe(true);
|
||||||
|
expect(app._fileBrowserState.normalState).toMatchObject({ phase: 'error', showHidden: true });
|
||||||
|
expect(app._fileBrowserState.normalState.error).toBe('<img src=x onerror=boom>');
|
||||||
|
expect(elements.fileBrowserTree.innerHTML).toContain('<img src=x onerror=boom>');
|
||||||
|
expect(elements.fileBrowserTree.innerHTML).not.toContain('<img src=x');
|
||||||
|
expect(pending).toHaveLength(2);
|
||||||
|
|
||||||
|
elements.fileBrowserSearch.value = '';
|
||||||
|
app.filterFileBrowser('');
|
||||||
|
expect(elements.fileBrowserTree.innerHTML).toContain('<img src=x onerror=boom>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a newer search result visible when the hidden reload fails, then reveals the stored error on clear', async () => {
|
||||||
|
const { app, elements, pending } = loadPanel();
|
||||||
|
await settleSearch(
|
||||||
|
app,
|
||||||
|
pending,
|
||||||
|
successfulData({ matches: [{ name: 'old.ts', path: 'old.ts', type: 'file' }], matchCount: 1 })
|
||||||
|
);
|
||||||
|
|
||||||
|
const toggle = app.toggleFileBrowserHidden();
|
||||||
|
await startSearch(app, 'new query');
|
||||||
|
pending[2].reply.resolve(
|
||||||
|
response(successfulData({ matches: [{ name: 'newer.ts', path: 'newer.ts', type: 'file' }], matchCount: 1 }))
|
||||||
|
);
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
pending[1].reply.reject(new Error('new preference failed'));
|
||||||
|
await toggle;
|
||||||
|
|
||||||
|
expect(elements.fileBrowserTree.innerHTML).toContain('newer.ts');
|
||||||
|
expect(app._fileBrowserState.normalState).toMatchObject({ phase: 'error', error: 'new preference failed' });
|
||||||
|
elements.fileBrowserSearch.value = '';
|
||||||
|
app.filterFileBrowser('');
|
||||||
|
expect(elements.fileBrowserTree.innerHTML).toContain('new preference failed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['ready', false],
|
||||||
|
['error', true],
|
||||||
|
])(
|
||||||
|
'reloads behind an unchanged overlength query-error and reveals the new normal %s state on clear',
|
||||||
|
async (_case, fail) => {
|
||||||
|
const { app, elements, pending } = loadPanel();
|
||||||
|
const query = 'x'.repeat(257);
|
||||||
|
elements.fileBrowserSearch.value = query;
|
||||||
|
app.filterFileBrowser(query);
|
||||||
|
const message = elements.fileBrowserTree.innerHTML;
|
||||||
|
const beforeSearchEpoch = app._fileBrowserState.searchEpoch;
|
||||||
|
const beforeTreeEpoch = app._fileBrowserState.treeEpoch;
|
||||||
|
|
||||||
|
const toggle = app.toggleFileBrowserHidden();
|
||||||
|
|
||||||
|
expect(app._fileBrowserState.searchEpoch).toBe(beforeSearchEpoch + 1);
|
||||||
|
expect(app._fileBrowserState.treeEpoch).toBe(beforeTreeEpoch + 1);
|
||||||
|
expect(app._fileBrowserState.view).toBe('query-error');
|
||||||
|
expect(elements.fileBrowserTree.innerHTML).toBe(message);
|
||||||
|
expect(pending).toHaveLength(1);
|
||||||
|
expect(pending[0].url).not.toContain('&q=');
|
||||||
|
if (fail) pending[0].reply.reject(new Error('hidden tree failed'));
|
||||||
|
else pending[0].reply.resolve(response(successfulTree('hidden-ready.ts')));
|
||||||
|
await toggle;
|
||||||
|
await vi.advanceTimersByTimeAsync(500);
|
||||||
|
|
||||||
|
expect(elements.fileBrowserTree.innerHTML).toBe(message);
|
||||||
|
expect(pending).toHaveLength(1);
|
||||||
|
elements.fileBrowserSearch.value = '';
|
||||||
|
app.filterFileBrowser('');
|
||||||
|
if (fail) expect(elements.fileBrowserTree.innerHTML).toContain('hidden tree failed');
|
||||||
|
else expect(elements.fileBrowserTree.innerHTML).toContain('hidden-ready.ts');
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
it('recovers a failed hidden transition through refresh without reverting the preference', async () => {
|
||||||
|
const { app, elements, pending } = loadPanel();
|
||||||
|
await settleSearch(app, pending, successfulData());
|
||||||
|
const toggle = app.toggleFileBrowserHidden();
|
||||||
|
pending[1].reply.reject(new Error('toggle failed'));
|
||||||
|
await toggle;
|
||||||
|
|
||||||
|
const refresh = app.refreshFileBrowser();
|
||||||
|
expect(app.fileBrowserShowHidden).toBe(true);
|
||||||
|
expect(pending[2].url).toContain('showHidden=true');
|
||||||
|
pending[2].reply.resolve(response(successfulTree('refresh-recovered.ts')));
|
||||||
|
await refresh;
|
||||||
|
expect(elements.fileBrowserTree.innerHTML).toContain('refresh-recovered.ts');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops the prior-preference normal cache safely when the tree surface is missing', async () => {
|
||||||
|
const { app, elements } = loadPanel();
|
||||||
|
app.fileBrowserData = successfulTree('old-preference.ts').data;
|
||||||
|
app._ensureFileBrowserState();
|
||||||
|
delete elements.fileBrowserTree;
|
||||||
|
|
||||||
|
await expect(app.toggleFileBrowserHidden()).resolves.toBeUndefined();
|
||||||
|
|
||||||
|
expect(app.fileBrowserShowHidden).toBe(true);
|
||||||
|
expect(app.fileBrowserData).toBeNull();
|
||||||
|
expect(app._fileBrowserState.normalState).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Expand and Collapse gating', () => {
|
||||||
|
it.each(['search-pending', 'search-results', 'search-error', 'query-error', 'normal'])(
|
||||||
|
'disables the Expand control for trimmed input while the %s view owns the panel',
|
||||||
|
(view) => {
|
||||||
|
const { app, elements } = loadPanel();
|
||||||
|
const state = app._ensureFileBrowserState();
|
||||||
|
state.view = view;
|
||||||
|
state.filter = ' query ';
|
||||||
|
elements.fileBrowserSearch.value = ' query ';
|
||||||
|
|
||||||
|
app._syncFileBrowserExpandBtn();
|
||||||
|
expect(elements.fileBrowserExpandBtn.disabled).toBe(true);
|
||||||
|
|
||||||
|
state.filter = ' ';
|
||||||
|
elements.fileBrowserSearch.value = ' ';
|
||||||
|
app._syncFileBrowserExpandBtn();
|
||||||
|
expect(elements.fileBrowserExpandBtn.disabled).toBe(false);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
it('guards Expand and Collapse without repainting or changing expansion state during a query', () => {
|
||||||
|
const { app, elements } = loadPanel();
|
||||||
|
app.fileBrowserData = successfulTree('tree.ts').data;
|
||||||
|
app._ensureFileBrowserState().filter = 'query';
|
||||||
|
elements.fileBrowserSearch.value = 'query';
|
||||||
|
elements.fileBrowserTree.innerHTML = '<div>search result sentinel</div>';
|
||||||
|
elements.fileBrowserExpandBtn.innerHTML = '\u229E';
|
||||||
|
const render = vi.spyOn(app, 'renderFileBrowserTree');
|
||||||
|
|
||||||
|
app.toggleFileBrowserExpand();
|
||||||
|
|
||||||
|
expect(app.fileBrowserAllExpanded).toBe(false);
|
||||||
|
expect(app.fileBrowserExpandedDirs.size).toBe(0);
|
||||||
|
expect(elements.fileBrowserExpandBtn.innerHTML).toBe('\u229E');
|
||||||
|
expect(elements.fileBrowserTree.innerHTML).toBe('<div>search result sentinel</div>');
|
||||||
|
expect(render).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('restores the control without changing its expansion state or icon when a search is cleared', () => {
|
||||||
|
const { app, elements } = loadPanel();
|
||||||
|
app.fileBrowserData = successfulTree('tree.ts').data;
|
||||||
|
app.fileBrowserAllExpanded = true;
|
||||||
|
app.fileBrowserExpandedDirs.add('existing');
|
||||||
|
elements.fileBrowserExpandBtn.innerHTML = '\u229F';
|
||||||
|
elements.fileBrowserSearch.value = 'query';
|
||||||
|
app.filterFileBrowser('query');
|
||||||
|
|
||||||
|
elements.fileBrowserSearch.value = '';
|
||||||
|
app.filterFileBrowser('');
|
||||||
|
|
||||||
|
expect(elements.fileBrowserExpandBtn.disabled).toBe(false);
|
||||||
|
expect(elements.fileBrowserExpandBtn.innerHTML).toBe('\u229F');
|
||||||
|
expect(app.fileBrowserAllExpanded).toBe(true);
|
||||||
|
expect(app.fileBrowserExpandedDirs.has('existing')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('search directory navigation', () => {
|
||||||
|
const unixTree = [
|
||||||
|
{
|
||||||
|
name: 'src',
|
||||||
|
path: 'src',
|
||||||
|
type: 'directory',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
name: 'components',
|
||||||
|
path: 'src/components',
|
||||||
|
type: 'directory',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
name: 'widgets',
|
||||||
|
path: 'src/components/widgets',
|
||||||
|
type: 'directory',
|
||||||
|
children: [{ name: 'index.ts', path: 'src/components/widgets/index.ts', type: 'file' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
it('finds a directory and its object-derived ancestors without parsing separators, including Windows paths', () => {
|
||||||
|
const { app } = loadPanel();
|
||||||
|
const windowsTree = [
|
||||||
|
{
|
||||||
|
name: 'src',
|
||||||
|
path: 'C:\\repo\\src',
|
||||||
|
type: 'directory',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
name: 'widgets',
|
||||||
|
path: 'C:\\repo\\src\\widgets',
|
||||||
|
type: 'directory',
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const found = app._findFileBrowserDirectory(windowsTree, 'C:\\repo\\src\\widgets');
|
||||||
|
|
||||||
|
expect(found.target.path).toBe('C:\\repo\\src\\widgets');
|
||||||
|
expect(found.ancestors).toEqual(['C:\\repo\\src']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves search explicitly for a ready directory and resets every search field without synthetic input', async () => {
|
||||||
|
const { app, elements, pending } = loadPanel();
|
||||||
|
app.fileBrowserData = successfulTree('unused', {
|
||||||
|
tree: unixTree,
|
||||||
|
totalFiles: 1,
|
||||||
|
totalDirectories: 3,
|
||||||
|
}).data;
|
||||||
|
app.fileBrowserAllExpanded = true;
|
||||||
|
app.fileBrowserExpandedDirs.add('already-open');
|
||||||
|
elements.fileBrowserExpandBtn.innerHTML = '\u229F';
|
||||||
|
await settleSearch(
|
||||||
|
app,
|
||||||
|
pending,
|
||||||
|
successfulData({
|
||||||
|
matches: [{ name: 'widgets', path: 'src/components/widgets', type: 'directory' }],
|
||||||
|
matchCount: 1,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const staleTimer = vi.fn();
|
||||||
|
app._fileBrowserState.inFlight = { timer: setTimeout(staleTimer, 10) };
|
||||||
|
app._fileBrowserState.deferredDirectoryTarget = { stale: true };
|
||||||
|
const beforeEpoch = app._fileBrowserState.searchEpoch;
|
||||||
|
const filter = vi.spyOn(app, 'filterFileBrowser');
|
||||||
|
|
||||||
|
elements.fileBrowserTree.querySelectorAll('.file-tree-item')[0].click();
|
||||||
|
await vi.advanceTimersByTimeAsync(20);
|
||||||
|
|
||||||
|
expect(app._fileBrowserState.searchEpoch).toBe(beforeEpoch + 1);
|
||||||
|
expect(app._fileBrowserState.inFlight).toBeNull();
|
||||||
|
expect(app._fileBrowserState.filter).toBe('');
|
||||||
|
expect(app.fileBrowserFilter).toBe('');
|
||||||
|
expect(app._fileBrowserState.matches).toEqual([]);
|
||||||
|
expect(app._fileBrowserState.deferredDirectoryTarget).toBeNull();
|
||||||
|
expect(app._fileBrowserState.view).toBe('normal');
|
||||||
|
expect(elements.fileBrowserSearch.value).toBe('');
|
||||||
|
expect(elements.fileBrowserExpandBtn.disabled).toBe(false);
|
||||||
|
expect(elements.fileBrowserExpandBtn.innerHTML).toBe('\u229F');
|
||||||
|
expect(app.fileBrowserAllExpanded).toBe(true);
|
||||||
|
expect([...app.fileBrowserExpandedDirs]).toEqual([
|
||||||
|
'already-open',
|
||||||
|
'src',
|
||||||
|
'src/components',
|
||||||
|
'src/components/widgets',
|
||||||
|
]);
|
||||||
|
expect(app.fileBrowserData).toBe(app._fileBrowserState.normalState.data);
|
||||||
|
expect(elements.fileBrowserTree.innerHTML).toContain('index.ts');
|
||||||
|
expect(staleTimer).not.toHaveBeenCalled();
|
||||||
|
expect(filter).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defers a directory transfer during the exact normal load while keeping results visible', async () => {
|
||||||
|
const { app, elements, pending } = loadPanel();
|
||||||
|
const treeLoad = app.loadFileBrowser('session/A');
|
||||||
|
await startSearch(app);
|
||||||
|
pending[1].reply.resolve(
|
||||||
|
response(
|
||||||
|
successfulData({
|
||||||
|
matches: [{ name: 'widgets', path: 'src/components/widgets', type: 'directory' }],
|
||||||
|
matchCount: 1,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
|
||||||
|
elements.fileBrowserTree.querySelectorAll('.file-tree-item')[0].click();
|
||||||
|
|
||||||
|
expect(elements.fileBrowserTree.innerHTML).toContain('widgets');
|
||||||
|
expect(app._fileBrowserState.view).toBe('search-results');
|
||||||
|
expect(app._fileBrowserState.deferredDirectoryTarget).toEqual({
|
||||||
|
ownerSessionId: 'session/A',
|
||||||
|
showHidden: false,
|
||||||
|
treeEpoch: 0,
|
||||||
|
searchEpoch: 1,
|
||||||
|
rawInput: 'widget',
|
||||||
|
query: 'widget',
|
||||||
|
path: 'src/components/widgets',
|
||||||
|
view: 'search-results',
|
||||||
|
});
|
||||||
|
|
||||||
|
pending[0].reply.resolve(
|
||||||
|
response(successfulTree('unused', { tree: unixTree, totalFiles: 1, totalDirectories: 3 }))
|
||||||
|
);
|
||||||
|
await treeLoad;
|
||||||
|
|
||||||
|
expect(app._fileBrowserState.view).toBe('normal');
|
||||||
|
expect(elements.fileBrowserTree.innerHTML).toContain('index.ts');
|
||||||
|
expect(app.fileBrowserExpandedDirs.has('src/components/widgets')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cancels a deferred directory when a new query arrives', async () => {
|
||||||
|
const { app, elements, pending } = loadPanel();
|
||||||
|
const treeLoad = app.loadFileBrowser('session/A');
|
||||||
|
await startSearch(app);
|
||||||
|
pending[1].reply.resolve(
|
||||||
|
response(
|
||||||
|
successfulData({
|
||||||
|
matches: [{ name: 'widgets', path: 'src/components/widgets', type: 'directory' }],
|
||||||
|
matchCount: 1,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
elements.fileBrowserTree.querySelectorAll('.file-tree-item')[0].click();
|
||||||
|
expect(app._fileBrowserState.deferredDirectoryTarget).not.toBeNull();
|
||||||
|
|
||||||
|
elements.fileBrowserSearch.value = 'new query';
|
||||||
|
app.filterFileBrowser('new query');
|
||||||
|
expect(app._fileBrowserState.deferredDirectoryTarget).toBeNull();
|
||||||
|
pending[0].reply.resolve(
|
||||||
|
response(successfulTree('unused', { tree: unixTree, totalFiles: 1, totalDirectories: 3 }))
|
||||||
|
);
|
||||||
|
await treeLoad;
|
||||||
|
|
||||||
|
expect(app._fileBrowserState.view).toBe('search-pending');
|
||||||
|
expect(elements.fileBrowserTree.innerHTML).toContain('Searching');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retains results, clears the deferred target, and toasts when the exact normal load fails', async () => {
|
||||||
|
const { app, elements, pending } = loadPanel();
|
||||||
|
const treeLoad = app.loadFileBrowser('session/A');
|
||||||
|
await startSearch(app);
|
||||||
|
pending[1].reply.resolve(
|
||||||
|
response(
|
||||||
|
successfulData({
|
||||||
|
matches: [{ name: 'widgets', path: 'src/components/widgets', type: 'directory' }],
|
||||||
|
matchCount: 1,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
elements.fileBrowserTree.querySelectorAll('.file-tree-item')[0].click();
|
||||||
|
|
||||||
|
pending[0].reply.reject(new Error('tree failed'));
|
||||||
|
await treeLoad;
|
||||||
|
|
||||||
|
expect(app._fileBrowserState.deferredDirectoryTarget).toBeNull();
|
||||||
|
expect(app._fileBrowserState.view).toBe('search-results');
|
||||||
|
expect(elements.fileBrowserTree.innerHTML).toContain('widgets');
|
||||||
|
expect(app.showToast).toHaveBeenCalledWith('Reload files before opening this folder', 'info');
|
||||||
|
expect(app._fileBrowserState.normalState).toMatchObject({ phase: 'error', error: 'tree failed' });
|
||||||
|
|
||||||
|
elements.fileBrowserSearch.value = '';
|
||||||
|
app.filterFileBrowser('');
|
||||||
|
expect(elements.fileBrowserTree.innerHTML).toContain('tree failed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['error', { phase: 'error', error: 'cached failure' }],
|
||||||
|
[
|
||||||
|
'ready cache missing the directory',
|
||||||
|
{
|
||||||
|
phase: 'ready',
|
||||||
|
data: successfulTree('other.ts').data,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
])('retains search results and prompts for reload for a compatible %s normal state', async (_case, normal) => {
|
||||||
|
const { app, elements, pending } = loadPanel();
|
||||||
|
const state = app._ensureFileBrowserState();
|
||||||
|
state.normalState = {
|
||||||
|
sessionId: 'session/A',
|
||||||
|
showHidden: false,
|
||||||
|
treeEpoch: 0,
|
||||||
|
...normal,
|
||||||
|
};
|
||||||
|
await settleSearch(
|
||||||
|
app,
|
||||||
|
pending,
|
||||||
|
successfulData({ matches: [{ name: 'missing', path: 'missing', type: 'directory' }], matchCount: 1 })
|
||||||
|
);
|
||||||
|
|
||||||
|
elements.fileBrowserTree.querySelectorAll('.file-tree-item')[0].click();
|
||||||
|
|
||||||
|
expect(app._fileBrowserState.view).toBe('search-results');
|
||||||
|
expect(app._fileBrowserState.filter).toBe('widget');
|
||||||
|
expect(elements.fileBrowserTree.innerHTML).toContain('missing');
|
||||||
|
expect(app.showToast).toHaveBeenCalledWith('Reload files before opening this folder', 'info');
|
||||||
|
expect(app._fileBrowserState.deferredDirectoryTarget).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['session', 'hidden preference', 'search epoch', 'tree epoch'])(
|
||||||
|
'does not complete a deferred transfer after a new %s invalidates its binding',
|
||||||
|
async (scenario) => {
|
||||||
|
const { app, elements, pending } = loadPanel();
|
||||||
|
app.loadFileBrowser('session/A');
|
||||||
|
await startSearch(app);
|
||||||
|
pending[1].reply.resolve(
|
||||||
|
response(
|
||||||
|
successfulData({
|
||||||
|
matches: [{ name: 'widgets', path: 'src/components/widgets', type: 'directory' }],
|
||||||
|
matchCount: 1,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
elements.fileBrowserTree.querySelectorAll('.file-tree-item')[0].click();
|
||||||
|
const ready = {
|
||||||
|
sessionId: 'session/A',
|
||||||
|
showHidden: false,
|
||||||
|
treeEpoch: 0,
|
||||||
|
phase: 'ready',
|
||||||
|
data: successfulTree('unused', { tree: unixTree, totalFiles: 1, totalDirectories: 3 }).data,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (scenario === 'session') {
|
||||||
|
app.activeSessionId = 'session/B';
|
||||||
|
app._fileBrowserState.ownerSessionId = 'session/B';
|
||||||
|
} else if (scenario === 'hidden preference') {
|
||||||
|
app.fileBrowserShowHidden = true;
|
||||||
|
} else if (scenario === 'search epoch') {
|
||||||
|
app._fileBrowserState.searchEpoch++;
|
||||||
|
} else {
|
||||||
|
app._fileBrowserState.treeEpoch++;
|
||||||
|
}
|
||||||
|
app._completeDeferredFileBrowserDirectory(ready);
|
||||||
|
|
||||||
|
expect(app._fileBrowserState.view).toBe('search-results');
|
||||||
|
expect(elements.fileBrowserSearch.value).toBe('widget');
|
||||||
|
expect(elements.fileBrowserTree.innerHTML).toContain('widgets');
|
||||||
|
expect(app.fileBrowserExpandedDirs.size).toBe(0);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
describe('normal tree loads', () => {
|
describe('normal tree loads', () => {
|
||||||
it('deduplicates compatible in-flight loads and reuses compatible ready and error states', async () => {
|
it('deduplicates compatible in-flight loads and reuses compatible ready and error states', async () => {
|
||||||
const ready = loadPanel();
|
const ready = loadPanel();
|
||||||
|
|||||||
Reference in New Issue
Block a user