/** * File Browser Component * * Modal file browser for navigating the filesystem and selecting files/directories. * Supports Git status display, file preview with Monaco editor, and diff viewing. * * @fires insert-path - When inserting a file path into terminal (detail: { path: string, type: 'file' | 'directory' }) * @fires open-in-editor - When opening a file in external editor (detail: { path: string }) * @fires directory-selected - When a directory is selected in 'select' mode (detail: string) * @fires browser-cancel - When the browser is cancelled or closed */ import { LitElement, html } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; import type { Session } from './session-list.js'; import { createLogger } from '../utils/logger.js'; const logger = createLogger('file-browser'); interface FileInfo { name: string; path: string; type: 'file' | 'directory'; size: number; modified: string; permissions?: string; isGitTracked?: boolean; gitStatus?: 'modified' | 'added' | 'deleted' | 'untracked' | 'unchanged'; } interface DirectoryListing { path: string; fullPath: string; gitStatus: GitStatus | null; files: FileInfo[]; } interface GitStatus { isGitRepo: boolean; branch?: string; modified: string[]; added: string[]; deleted: string[]; untracked: string[]; } interface FilePreview { type: 'image' | 'text' | 'binary'; content?: string; language?: string; url?: string; mimeType?: string; size: number; humanSize?: string; } interface FileDiff { path: string; diff: string; hasDiff: boolean; } @customElement('file-browser') export class FileBrowser extends LitElement { // Disable shadow DOM to use Tailwind createRenderRoot() { return this; } @property({ type: Boolean }) visible = false; @property({ type: String }) mode: 'browse' | 'select' = 'browse'; @property({ type: Object }) session: Session | null = null; @state() private currentPath = ''; @state() private files: FileInfo[] = []; @state() private loading = false; @state() private selectedFile: FileInfo | null = null; @state() private preview: FilePreview | null = null; @state() private diff: FileDiff | null = null; @state() private gitFilter: 'all' | 'changed' = 'all'; @state() private showHidden = false; @state() private gitStatus: GitStatus | null = null; @state() private previewLoading = false; @state() private showDiff = false; private monacoEditor: { setValue: (value: string) => void; getModel: () => unknown; dispose: () => void; } | null = null; private monacoContainer: HTMLElement | null = null; async connectedCallback() { super.connectedCallback(); if (this.visible) { this.currentPath = this.session?.workingDir || '.'; await this.loadDirectory(this.currentPath); } document.addEventListener('keydown', this.handleKeyDown); } async updated(changedProperties: Map) { super.updated(changedProperties); if (changedProperties.has('visible') || changedProperties.has('session')) { if (this.visible) { this.currentPath = this.session?.workingDir || '.'; await this.loadDirectory(this.currentPath); } } if (this.preview?.type === 'text' && this.monacoContainer && !this.monacoEditor) { this.initMonacoEditor(); } } private async loadDirectory(dirPath: string) { this.loading = true; try { const params = new URLSearchParams({ path: dirPath, showHidden: this.showHidden.toString(), gitFilter: this.gitFilter, }); const url = `/api/fs/browse?${params}`; logger.debug(`loading directory: ${dirPath}`); logger.debug(`fetching URL: ${url}`); const response = await fetch(url); logger.debug(`response status: ${response.status}`); if (response.ok) { const data: DirectoryListing = await response.json(); logger.debug(`received ${data.files?.length || 0} files`); this.currentPath = data.path; this.files = data.files || []; this.gitStatus = data.gitStatus; } else { const errorData = await response.text(); logger.error(`failed to load directory: ${response.status}`, new Error(errorData)); } } catch (error) { logger.error('error loading directory:', error); } finally { this.loading = false; } } private async loadPreview(file: FileInfo) { if (file.type === 'directory') return; this.previewLoading = true; this.selectedFile = file; this.showDiff = false; try { logger.debug(`loading preview for file: ${file.name}`); logger.debug(`file path: ${file.path}`); const response = await fetch(`/api/fs/preview?path=${encodeURIComponent(file.path)}`); if (response.ok) { this.preview = await response.json(); if (this.preview?.type === 'text') { // Update Monaco editor if it exists this.updateMonacoContent(); } } else { logger.error(`preview failed: ${response.status}`, new Error(await response.text())); } } catch (error) { logger.error('error loading preview:', error); } finally { this.previewLoading = false; } } private async loadDiff(file: FileInfo) { if (file.type === 'directory' || !file.gitStatus || file.gitStatus === 'unchanged') return; this.previewLoading = true; this.showDiff = true; try { const response = await fetch(`/api/fs/diff?path=${encodeURIComponent(file.path)}`); if (response.ok) { this.diff = await response.json(); } } catch (error) { logger.error('error loading diff:', error); } finally { this.previewLoading = false; } } private initMonacoEditor() { if (!window.monaco || !this.monacoContainer) return; this.monacoEditor = window.monaco.editor.create(this.monacoContainer, { value: this.preview?.content || '', language: this.preview?.language || 'plaintext', theme: 'vs-dark', readOnly: true, automaticLayout: true, minimap: { enabled: false }, scrollBeyondLastLine: false, }); } private updateMonacoContent() { if (!this.monacoEditor || !this.preview) return; this.monacoEditor.setValue(this.preview.content || ''); window.monaco.editor.setModelLanguage( this.monacoEditor.getModel(), this.preview.language || 'plaintext' ); } private handleFileClick(file: FileInfo) { if (file.type === 'directory') { this.loadDirectory(file.path); } else { this.loadPreview(file); } } private async copyToClipboard(text: string) { try { await navigator.clipboard.writeText(text); logger.debug(`copied to clipboard: ${text}`); } catch (err) { logger.error('failed to copy to clipboard:', err); } } private insertPathIntoTerminal() { if (!this.selectedFile) return; // Dispatch event with the file path this.dispatchEvent( new CustomEvent('insert-path', { detail: { path: this.selectedFile.path, type: this.selectedFile.type, }, bubbles: true, composed: true, }) ); // Close the file browser this.dispatchEvent(new CustomEvent('browser-cancel')); } private openInEditor() { if (!this.selectedFile || this.selectedFile.type !== 'file') return; // Dispatch event to open file in editor this.dispatchEvent( new CustomEvent('open-in-editor', { detail: { path: this.selectedFile.path, }, bubbles: true, composed: true, }) ); // Close the file browser this.dispatchEvent(new CustomEvent('browser-cancel')); } private handleParentClick() { const parentPath = this.currentPath.split('/').slice(0, -1).join('/') || '.'; this.loadDirectory(parentPath); } private toggleGitFilter() { this.gitFilter = this.gitFilter === 'all' ? 'changed' : 'all'; this.loadDirectory(this.currentPath); } private toggleHidden() { this.showHidden = !this.showHidden; this.loadDirectory(this.currentPath); } private toggleDiff() { if ( this.selectedFile && this.selectedFile.gitStatus && this.selectedFile.gitStatus !== 'unchanged' ) { if (this.showDiff) { this.loadPreview(this.selectedFile); } else { this.loadDiff(this.selectedFile); } } } private handleSelect() { if (this.mode === 'select' && this.currentPath) { this.dispatchEvent( new CustomEvent('directory-selected', { detail: this.currentPath, }) ); } } private handleCancel() { this.dispatchEvent(new CustomEvent('browser-cancel')); } private handleOverlayClick(e: Event) { if (e.target === e.currentTarget) { this.handleCancel(); } } private renderFileIcon(file: FileInfo) { if (file.type === 'directory') { return html` `; } const ext = file.name.split('.').pop()?.toLowerCase(); // JavaScript/TypeScript files if (ext === 'js' || ext === 'mjs' || ext === 'cjs') { return html` `; } if (ext === 'ts' || ext === 'tsx') { return html` `; } if (ext === 'jsx') { return html` `; } // Web files if (ext === 'html' || ext === 'htm') { return html` `; } if (ext === 'css' || ext === 'scss' || ext === 'sass' || ext === 'less') { return html` `; } // Config and data files if (ext === 'json' || ext === 'jsonc') { return html` `; } if (ext === 'xml' || ext === 'yaml' || ext === 'yml') { return html` `; } // Documentation if (ext === 'md' || ext === 'markdown') { return html` `; } if (ext === 'txt' || ext === 'text') { return html` `; } // Images if ( ext === 'png' || ext === 'jpg' || ext === 'jpeg' || ext === 'gif' || ext === 'webp' || ext === 'bmp' ) { return html` `; } if (ext === 'svg') { return html` `; } // Archives if (ext === 'zip' || ext === 'tar' || ext === 'gz' || ext === 'rar' || ext === '7z') { return html` `; } // Documents if (ext === 'pdf') { return html` `; } // Executables and scripts if (ext === 'sh' || ext === 'bash' || ext === 'zsh' || ext === 'fish') { return html` `; } // Default file icon return html` `; } private renderGitStatus(status?: FileInfo['gitStatus']) { if (!status || status === 'unchanged') return ''; const labels: Record = { modified: 'M', added: 'A', deleted: 'D', untracked: '?', }; const colorClasses: Record = { modified: 'bg-yellow-900/50 text-yellow-400', added: 'bg-green-900/50 text-green-400', deleted: 'bg-red-900/50 text-red-400', untracked: 'bg-gray-700 text-gray-400', }; return html` ${labels[status]} `; } private renderPreview() { if (this.previewLoading) { return html`
Loading preview...
`; } if (this.showDiff && this.diff) { return this.renderDiff(); } if (!this.preview) { return html`
Select a file to preview
`; } switch (this.preview.type) { case 'image': return html`
${this.selectedFile?.name}
`; case 'text': return html`
{ this.monacoContainer = e.target as HTMLElement; this.initMonacoEditor(); }} >
`; case 'binary': return html`
Binary File
${this.preview.humanSize || this.preview.size + ' bytes'}
${this.preview.mimeType || 'Unknown type'}
`; } } private renderDiff() { if (!this.diff || !this.diff.diff) { return html`
No changes in this file
`; } const lines = this.diff.diff.split('\n'); return html`
${lines.map((line) => { let className = 'text-dark-text-muted'; if (line.startsWith('+')) className = 'text-status-success bg-green-900/20'; else if (line.startsWith('-')) className = 'text-status-error bg-red-900/20'; else if (line.startsWith('@@')) className = 'text-accent-blue font-semibold'; return html`
${line}
`; })}
`; } render() { if (!this.visible) { return html``; } return html`
e.stopPropagation()} >

File Browser

${this.session ? html` Session: ${this.session.name || this.session.id} ` : ''}
${this.gitStatus ? html` ${this.gitStatus.branch} ` : ''} ${this.currentPath}
${this.loading ? html`
Loading...
` : html` ${this.currentPath !== '.' && this.currentPath !== '/' ? html`
..
` : ''} ${this.files.map( (file) => html`
this.handleFileClick(file)} > ${this.renderFileIcon(file)} ${file.name} ${this.renderGitStatus(file.gitStatus)}
` )} `}
${this.selectedFile ? html`
${this.renderFileIcon(this.selectedFile)} ${this.selectedFile.name} ${this.renderGitStatus(this.selectedFile.gitStatus)}
${this.selectedFile.type === 'file' ? html` ` : ''} ${this.selectedFile.gitStatus && this.selectedFile.gitStatus !== 'unchanged' ? html` ` : ''}
` : ''}
${this.renderPreview()}
${this.mode === 'select' ? html`
` : ''}
`; } disconnectedCallback() { super.disconnectedCallback(); document.removeEventListener('keydown', this.handleKeyDown); if (this.monacoEditor) { this.monacoEditor.dispose(); this.monacoEditor = null; } } private handleKeyDown = (e: KeyboardEvent) => { if (!this.visible) return; if (e.key === 'Escape') { e.preventDefault(); this.handleCancel(); } else if (e.key === 'Enter' && this.selectedFile && this.selectedFile.type === 'file') { e.preventDefault(); this.insertPathIntoTerminal(); } else if ((e.metaKey || e.ctrlKey) && e.key === 'c' && this.selectedFile) { e.preventDefault(); this.copyToClipboard(this.selectedFile.path); } }; }