mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-27 15:17:38 +00:00
Fix file browser constant refresh issue (#354)
This commit is contained in:
parent
6c4adfe10f
commit
7cef4c1641
3 changed files with 317 additions and 6 deletions
294
web/src/client/components/file-browser.test.ts
Normal file
294
web/src/client/components/file-browser.test.ts
Normal file
|
|
@ -0,0 +1,294 @@
|
||||||
|
// @vitest-environment happy-dom
|
||||||
|
import { fixture, html } from '@open-wc/testing';
|
||||||
|
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import {
|
||||||
|
restoreLocalStorage,
|
||||||
|
setupFetchMock,
|
||||||
|
setupLocalStorageMock,
|
||||||
|
waitForAsync,
|
||||||
|
} from '@/test/utils/component-helpers';
|
||||||
|
import type { AuthClient } from '../services/auth-client';
|
||||||
|
import type { Session } from './session-list';
|
||||||
|
|
||||||
|
// Mock AuthClient
|
||||||
|
vi.mock('../services/auth-client');
|
||||||
|
|
||||||
|
// Import component type
|
||||||
|
import type { FileBrowser } from './file-browser';
|
||||||
|
|
||||||
|
describe('FileBrowser', () => {
|
||||||
|
let element: FileBrowser;
|
||||||
|
let fetchMock: ReturnType<typeof setupFetchMock>;
|
||||||
|
let mockAuthClient: AuthClient;
|
||||||
|
let _localStorageMock: ReturnType<typeof setupLocalStorageMock>;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
// Import components to register custom elements
|
||||||
|
await import('./file-browser');
|
||||||
|
await import('./modal-wrapper');
|
||||||
|
await import('./monaco-editor');
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Setup localStorage mock with isolation
|
||||||
|
_localStorageMock = setupLocalStorageMock();
|
||||||
|
|
||||||
|
// Setup fetch mock
|
||||||
|
fetchMock = setupFetchMock();
|
||||||
|
|
||||||
|
// Create mock auth client
|
||||||
|
mockAuthClient = {
|
||||||
|
getAuthHeader: vi.fn(() => ({ Authorization: 'Bearer test-token' })),
|
||||||
|
} as unknown as AuthClient;
|
||||||
|
|
||||||
|
// Mock authClient.instance
|
||||||
|
vi.mocked(await import('../services/auth-client')).authClient = mockAuthClient;
|
||||||
|
|
||||||
|
// Create component
|
||||||
|
element = await fixture<FileBrowser>(html`
|
||||||
|
<file-browser
|
||||||
|
.visible=${false}
|
||||||
|
.mode=${'browse'}
|
||||||
|
.session=${{ id: 'test-session', workingDir: '/home/user' } as Session}
|
||||||
|
></file-browser>
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
restoreLocalStorage();
|
||||||
|
fetchMock.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('handleSelect method - absolute path fix', () => {
|
||||||
|
it('should use currentFullPath when available instead of currentPath', async () => {
|
||||||
|
// Set up the component in select mode but not visible yet
|
||||||
|
element.mode = 'select';
|
||||||
|
|
||||||
|
// Manually set the internal state to simulate a directory with relative path
|
||||||
|
// This simulates the state after navigating to a directory that returned relative paths
|
||||||
|
element.currentPath = '../..';
|
||||||
|
element.currentFullPath = '/Users/steipete/Desktop';
|
||||||
|
|
||||||
|
// Now make it visible (this would normally trigger a load, but we'll skip that)
|
||||||
|
element.visible = true;
|
||||||
|
await element.updateComplete;
|
||||||
|
|
||||||
|
// Listen for directory-selected event
|
||||||
|
const eventPromise = new Promise<CustomEvent>((resolve) => {
|
||||||
|
element.addEventListener('directory-selected', resolve as EventListener, { once: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Call handleSelect directly (simulating a click on select button)
|
||||||
|
// biome-ignore lint/suspicious/noExplicitAny: Need to access private method for testing
|
||||||
|
(element as any).handleSelect();
|
||||||
|
|
||||||
|
// Wait for event
|
||||||
|
const event = await eventPromise;
|
||||||
|
|
||||||
|
// Verify the event contains the absolute path (currentFullPath), not the relative path
|
||||||
|
expect(event.detail).toBe('/Users/steipete/Desktop');
|
||||||
|
expect(event.detail).not.toBe('../..');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fall back to currentPath when currentFullPath is not available', async () => {
|
||||||
|
// Set up the component in select mode
|
||||||
|
element.mode = 'select';
|
||||||
|
|
||||||
|
// Mock the initial directory load that will happen when visible is set
|
||||||
|
fetchMock.mockResponse(
|
||||||
|
`/api/fs/browse?path=${encodeURIComponent('/home/user')}&showHidden=false&gitFilter=all`,
|
||||||
|
{
|
||||||
|
path: '/home/user',
|
||||||
|
fullPath: '/home/user',
|
||||||
|
gitStatus: null,
|
||||||
|
files: [],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
element.visible = true;
|
||||||
|
await element.updateComplete;
|
||||||
|
await waitForAsync();
|
||||||
|
|
||||||
|
// Now manually override to simulate old server response without fullPath
|
||||||
|
element.currentPath = '/home/user/projects';
|
||||||
|
element.currentFullPath = '';
|
||||||
|
|
||||||
|
// Listen for directory-selected event
|
||||||
|
const eventPromise = new Promise<CustomEvent>((resolve) => {
|
||||||
|
element.addEventListener('directory-selected', resolve as EventListener, { once: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Call handleSelect directly
|
||||||
|
// biome-ignore lint/suspicious/noExplicitAny: Need to access private method for testing
|
||||||
|
(element as any).handleSelect();
|
||||||
|
|
||||||
|
// Wait for event
|
||||||
|
const event = await eventPromise;
|
||||||
|
|
||||||
|
// Should use currentPath as fallback
|
||||||
|
expect(event.detail).toBe('/home/user/projects');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not dispatch event when currentPath is empty', async () => {
|
||||||
|
element.mode = 'select';
|
||||||
|
|
||||||
|
// Mock the initial directory load
|
||||||
|
fetchMock.mockResponse(
|
||||||
|
`/api/fs/browse?path=${encodeURIComponent('/home/user')}&showHidden=false&gitFilter=all`,
|
||||||
|
{
|
||||||
|
path: '/home/user',
|
||||||
|
fullPath: '/home/user',
|
||||||
|
gitStatus: null,
|
||||||
|
files: [],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
element.visible = true;
|
||||||
|
await element.updateComplete;
|
||||||
|
await waitForAsync();
|
||||||
|
|
||||||
|
// Now clear the paths to test empty path behavior
|
||||||
|
element.currentPath = '';
|
||||||
|
element.currentFullPath = '';
|
||||||
|
|
||||||
|
// Listen for directory-selected event
|
||||||
|
let eventFired = false;
|
||||||
|
element.addEventListener('directory-selected', () => {
|
||||||
|
eventFired = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Call handleSelect directly
|
||||||
|
// biome-ignore lint/suspicious/noExplicitAny: Need to access private method for testing
|
||||||
|
(element as any).handleSelect();
|
||||||
|
|
||||||
|
// Wait a bit to ensure no event is fired
|
||||||
|
await waitForAsync(50);
|
||||||
|
|
||||||
|
expect(eventFired).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not dispatch event when not in select mode', async () => {
|
||||||
|
// Setup component in browse mode
|
||||||
|
element.mode = 'browse';
|
||||||
|
element.visible = true;
|
||||||
|
element.currentPath = '/some/path';
|
||||||
|
element.currentFullPath = '/Users/test/some/path';
|
||||||
|
await element.updateComplete;
|
||||||
|
|
||||||
|
// Listen for directory-selected event
|
||||||
|
let eventFired = false;
|
||||||
|
element.addEventListener('directory-selected', () => {
|
||||||
|
eventFired = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Call handleSelect directly
|
||||||
|
// biome-ignore lint/suspicious/noExplicitAny: Need to access private method for testing
|
||||||
|
(element as any).handleSelect();
|
||||||
|
|
||||||
|
await waitForAsync(50);
|
||||||
|
|
||||||
|
expect(eventFired).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('integration with directory navigation', () => {
|
||||||
|
it('should correctly pass absolute path when selecting after navigation', async () => {
|
||||||
|
element.mode = 'select';
|
||||||
|
|
||||||
|
// Mock initial load response when component becomes visible
|
||||||
|
// File browser uses /api/fs/browse when not in noAuthMode
|
||||||
|
fetchMock.mockResponse(
|
||||||
|
`/api/fs/browse?path=${encodeURIComponent('/home/user')}&showHidden=false&gitFilter=all`,
|
||||||
|
{
|
||||||
|
path: '/home/user',
|
||||||
|
fullPath: '/home/user',
|
||||||
|
gitStatus: null,
|
||||||
|
files: [
|
||||||
|
{
|
||||||
|
name: 'Desktop',
|
||||||
|
path: 'Desktop',
|
||||||
|
type: 'directory',
|
||||||
|
size: 0,
|
||||||
|
modified: '2025-01-15T10:00:00Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
element.visible = true;
|
||||||
|
await element.updateComplete;
|
||||||
|
await waitForAsync();
|
||||||
|
|
||||||
|
// Mock response for navigating to Desktop with relative paths
|
||||||
|
// File browser uses /api/fs/browse when not in noAuthMode
|
||||||
|
fetchMock.mockResponse(
|
||||||
|
`/api/fs/browse?path=${encodeURIComponent('Desktop')}&showHidden=false&gitFilter=all`,
|
||||||
|
{
|
||||||
|
path: '..',
|
||||||
|
fullPath: '/Users/steipete/Desktop',
|
||||||
|
gitStatus: null,
|
||||||
|
files: [
|
||||||
|
{
|
||||||
|
name: '..',
|
||||||
|
path: '../..',
|
||||||
|
type: 'directory',
|
||||||
|
size: 0,
|
||||||
|
modified: '2025-01-15T10:00:00Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Simulate navigation to Desktop
|
||||||
|
await element.loadDirectory('Desktop');
|
||||||
|
await waitForAsync();
|
||||||
|
|
||||||
|
// After loading, currentPath will be set to fullPath (due to line 179 in file-browser.ts)
|
||||||
|
// and currentFullPath will also be set to fullPath
|
||||||
|
expect(element.currentPath).toBe('/Users/steipete/Desktop');
|
||||||
|
expect(element.currentFullPath).toBe('/Users/steipete/Desktop');
|
||||||
|
|
||||||
|
// Listen for directory-selected event
|
||||||
|
const eventPromise = new Promise<CustomEvent>((resolve) => {
|
||||||
|
element.addEventListener('directory-selected', resolve as EventListener, { once: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Select this directory
|
||||||
|
// biome-ignore lint/suspicious/noExplicitAny: Need to access private method for testing
|
||||||
|
(element as any).handleSelect();
|
||||||
|
const event = await eventPromise;
|
||||||
|
|
||||||
|
// Should get absolute path
|
||||||
|
expect(event.detail).toBe('/Users/steipete/Desktop');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle multiple parent directory navigations correctly', async () => {
|
||||||
|
element.mode = 'select';
|
||||||
|
|
||||||
|
// Start at a deep directory
|
||||||
|
element.currentPath = '.';
|
||||||
|
element.currentFullPath = '/Users/steipete/Projects/vibetunnel/web';
|
||||||
|
element.visible = true;
|
||||||
|
await element.updateComplete;
|
||||||
|
|
||||||
|
// Simulate state after navigating up multiple levels
|
||||||
|
element.currentPath = '../../..';
|
||||||
|
element.currentFullPath = '/Users/steipete';
|
||||||
|
|
||||||
|
// Listen for event
|
||||||
|
const eventPromise = new Promise<CustomEvent>((resolve) => {
|
||||||
|
element.addEventListener('directory-selected', resolve as EventListener, { once: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Select directory
|
||||||
|
// biome-ignore lint/suspicious/noExplicitAny: Need to access private method for testing
|
||||||
|
(element as any).handleSelect();
|
||||||
|
const event = await eventPromise;
|
||||||
|
|
||||||
|
// Should return absolute path, not relative
|
||||||
|
expect(event.detail).toBe('/Users/steipete');
|
||||||
|
expect(event.detail).not.toContain('..');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -131,7 +131,7 @@ export class FileBrowser extends LitElement {
|
||||||
async updated(changedProperties: Map<string, unknown>) {
|
async updated(changedProperties: Map<string, unknown>) {
|
||||||
super.updated(changedProperties);
|
super.updated(changedProperties);
|
||||||
|
|
||||||
// Only load directory when the component becomes visible or when session changes while visible
|
// Only load directory when the component becomes visible or when session's workingDir actually changes
|
||||||
if (changedProperties.has('visible')) {
|
if (changedProperties.has('visible')) {
|
||||||
if (this.visible) {
|
if (this.visible) {
|
||||||
// Component just became visible
|
// Component just became visible
|
||||||
|
|
@ -139,9 +139,17 @@ export class FileBrowser extends LitElement {
|
||||||
await this.loadDirectory(this.currentPath);
|
await this.loadDirectory(this.currentPath);
|
||||||
}
|
}
|
||||||
} else if (changedProperties.has('session') && this.visible) {
|
} else if (changedProperties.has('session') && this.visible) {
|
||||||
// Session changed while component is visible
|
// Check if the workingDir actually changed
|
||||||
this.currentPath = this.session?.workingDir || '.';
|
const oldSession = changedProperties.get('session') as Session | null;
|
||||||
await this.loadDirectory(this.currentPath);
|
const oldWorkingDir = oldSession?.workingDir;
|
||||||
|
const newWorkingDir = this.session?.workingDir;
|
||||||
|
|
||||||
|
if (oldWorkingDir !== newWorkingDir) {
|
||||||
|
// Working directory actually changed, reload
|
||||||
|
this.currentPath = newWorkingDir || '.';
|
||||||
|
await this.loadDirectory(this.currentPath);
|
||||||
|
}
|
||||||
|
// If only the session object reference changed but workingDir is the same, don't reload
|
||||||
}
|
}
|
||||||
|
|
||||||
// Monaco editor will handle its own updates through properties
|
// Monaco editor will handle its own updates through properties
|
||||||
|
|
@ -167,7 +175,8 @@ export class FileBrowser extends LitElement {
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data: DirectoryListing = await response.json();
|
const data: DirectoryListing = await response.json();
|
||||||
logger.debug(`received ${data.files?.length || 0} files`);
|
logger.debug(`received ${data.files?.length || 0} files`);
|
||||||
this.currentPath = data.path;
|
// Use the absolute path (fullPath) instead of the potentially relative path
|
||||||
|
this.currentPath = data.fullPath || data.path;
|
||||||
this.currentFullPath = data.fullPath;
|
this.currentFullPath = data.fullPath;
|
||||||
this.files = data.files || [];
|
this.files = data.files || [];
|
||||||
this.gitStatus = data.gitStatus;
|
this.gitStatus = data.gitStatus;
|
||||||
|
|
@ -256,6 +265,7 @@ export class FileBrowser extends LitElement {
|
||||||
|
|
||||||
private handleFileClick(file: FileInfo) {
|
private handleFileClick(file: FileInfo) {
|
||||||
if (file.type === 'directory') {
|
if (file.type === 'directory') {
|
||||||
|
// Use the absolute path provided by the server
|
||||||
this.loadDirectory(file.path);
|
this.loadDirectory(file.path);
|
||||||
} else {
|
} else {
|
||||||
// Set the selected file
|
// Set the selected file
|
||||||
|
|
@ -379,7 +389,7 @@ export class FileBrowser extends LitElement {
|
||||||
if (this.mode === 'select' && this.currentPath) {
|
if (this.mode === 'select' && this.currentPath) {
|
||||||
this.dispatchEvent(
|
this.dispatchEvent(
|
||||||
new CustomEvent('directory-selected', {
|
new CustomEvent('directory-selected', {
|
||||||
detail: this.currentPath,
|
detail: this.currentFullPath || this.currentPath,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,13 @@ const HOME_PATTERN = /^(?:\/Users\/[^/]+|\/home\/[^/]+|[A-Za-z]:[/\\]Users[/\\][
|
||||||
export function formatPathForDisplay(path: string): string {
|
export function formatPathForDisplay(path: string): string {
|
||||||
if (!path) return '';
|
if (!path) return '';
|
||||||
|
|
||||||
|
// If the path is already using ~ notation, return as-is
|
||||||
|
if (path.startsWith('~')) {
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply home directory replacement for absolute paths
|
||||||
|
// This includes Unix paths (starting with /) and Windows paths (starting with drive letter)
|
||||||
// Use pre-compiled regex for better performance
|
// Use pre-compiled regex for better performance
|
||||||
// The regex safely matches home directories without being affected by special characters in usernames
|
// The regex safely matches home directories without being affected by special characters in usernames
|
||||||
return path.replace(HOME_PATTERN, '~');
|
return path.replace(HOME_PATTERN, '~');
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue