diff --git a/packages/desktop/src-tauri/src/commands/files.rs b/packages/desktop/src-tauri/src/commands/files.rs index edb4f58f..89a0204c 100644 --- a/packages/desktop/src-tauri/src/commands/files.rs +++ b/packages/desktop/src-tauri/src/commands/files.rs @@ -135,6 +135,7 @@ impl From for FsCommandError { #[tauri::command] pub async fn list_directory( path: Option, + respect_gitignore: Option, state: tauri::State<'_, DesktopRuntime>, ) -> Result { let (workspace_roots, default_root) = resolve_workspace_roots(state.settings()).await; @@ -164,18 +165,62 @@ pub async fn list_directory( .await .map_err(|err| FsCommandError::from(err).to_list_message())?; + // Collect all entry names first for gitignore check + let mut all_entries: Vec<(tokio::fs::DirEntry, String)> = Vec::new(); while let Some(entry) = dir_entries .next_entry() .await .map_err(|err| FsCommandError::from(err).to_list_message())? { + let name = entry.file_name().to_string_lossy().to_string(); + all_entries.push((entry, name)); + } + + // Get gitignored paths if requested + let ignored_names: HashSet = if respect_gitignore.unwrap_or(false) { + let names: Vec = all_entries.iter().map(|(_, name)| name.clone()).collect(); + if names.is_empty() { + HashSet::new() + } else { + let cwd = resolved_path.clone(); + tokio::task::spawn_blocking(move || { + let output = Command::new("git") + .arg("check-ignore") + .arg("--") + .args(&names) + .current_dir(&cwd) + .output(); + + match output { + Ok(out) => { + String::from_utf8_lossy(&out.stdout) + .lines() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect() + } + Err(_) => HashSet::new(), + } + }) + .await + .unwrap_or_default() + } + } else { + HashSet::new() + }; + + for (entry, name) in all_entries { + // Skip gitignored entries + if !ignored_names.is_empty() && ignored_names.contains(&name) { + continue; + } + let file_type = entry .file_type() .await .map_err(|err| FsCommandError::from(err).to_list_message())?; let entry_path = entry.path(); - let name = entry.file_name().to_string_lossy().to_string(); let mut is_directory = file_type.is_dir(); let is_symlink = file_type.is_symlink(); @@ -631,6 +676,43 @@ pub struct ReadFileResponse { path: String, } +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ReadFileBinaryResponse { + data_url: String, + path: String, +} + +fn get_image_mime_type(file_path: &str) -> &'static str { + let lower = file_path.to_lowercase(); + if lower.ends_with(".png") { + return "image/png"; + } + if lower.ends_with(".jpg") || lower.ends_with(".jpeg") { + return "image/jpeg"; + } + if lower.ends_with(".gif") { + return "image/gif"; + } + if lower.ends_with(".svg") { + return "image/svg+xml"; + } + if lower.ends_with(".webp") { + return "image/webp"; + } + if lower.ends_with(".ico") { + return "image/x-icon"; + } + if lower.ends_with(".bmp") { + return "image/bmp"; + } + if lower.ends_with(".avif") { + return "image/avif"; + } + + "application/octet-stream" +} + #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct WriteFileResponse { @@ -689,6 +771,50 @@ pub async fn read_file( }) } +#[tauri::command] +pub async fn read_file_binary( + path: String, + state: tauri::State<'_, DesktopRuntime>, +) -> Result { + use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; + + const MAX_BYTES: u64 = 10 * 1024 * 1024; + + let trimmed = path.trim(); + if trimmed.is_empty() { + return Err("Path is required".to_string()); + } + + let (workspace_roots, default_root) = resolve_workspace_roots(state.settings()).await; + let resolved_path = resolve_sandboxed_path(Some(trimmed.to_string()), &workspace_roots, default_root.as_ref()) + .await + .map_err(|_| "File not found or access denied".to_string())?; + + let metadata = fs::metadata(&resolved_path) + .await + .map_err(|_| "File not found".to_string())?; + + if !metadata.is_file() { + return Err("Specified path is not a file".to_string()); + } + + if metadata.len() > MAX_BYTES { + return Err("File too large".to_string()); + } + + let bytes = fs::read(&resolved_path) + .await + .map_err(|err| format!("Failed to read file: {}", err))?; + + let mime_type = get_image_mime_type(trimmed); + let data_url = format!("data:{};base64,{}", mime_type, BASE64.encode(&bytes)); + + Ok(ReadFileBinaryResponse { + data_url, + path: normalize_path(&resolved_path), + }) +} + #[tauri::command] pub async fn write_file( path: String, diff --git a/packages/desktop/src-tauri/src/main.rs b/packages/desktop/src-tauri/src/main.rs index 8b94ec64..ac32cdea 100644 --- a/packages/desktop/src-tauri/src/main.rs +++ b/packages/desktop/src-tauri/src/main.rs @@ -28,7 +28,7 @@ use axum::{ routing::{any, get, post}, Json, Router, }; -use commands::files::{create_directory, exec_commands, list_directory, read_file, search_files, write_file}; +use commands::files::{create_directory, exec_commands, list_directory, read_file, read_file_binary, search_files, write_file}; use commands::git::{ add_git_worktree, check_is_git_repository, checkout_branch, create_branch, create_git_commit, rename_branch, create_git_identity, delete_git_branch, delete_git_identity, delete_remote_branch, @@ -836,6 +836,7 @@ fn main() { search_files, create_directory, read_file, + read_file_binary, write_file, exec_commands, request_directory_access, diff --git a/packages/desktop/src/api/files.ts b/packages/desktop/src/api/files.ts index c0d586d3..4966816a 100644 --- a/packages/desktop/src/api/files.ts +++ b/packages/desktop/src/api/files.ts @@ -1,6 +1,11 @@ import { safeInvoke } from '../lib/tauriCallbackManager'; -import type { DirectoryListResult, FileSearchQuery, FileSearchResult, FilesAPI } from '@openchamber/ui/lib/api/types'; +import type { DirectoryListResult, FileSearchQuery, FileSearchResult, FilesAPI, ListDirectoryOptions } from '@openchamber/ui/lib/api/types'; + +type ReadFileBinaryResponse = { + dataUrl: string; + path: string; +}; type ListDirectoryResponse = DirectoryListResult & { path?: string; @@ -39,11 +44,12 @@ const normalizeDirectoryPayload = (result: ListDirectoryResponse): DirectoryList }); export const createDesktopFilesAPI = (): FilesAPI => ({ - async listDirectory(path: string): Promise { + async listDirectory(path: string, options?: ListDirectoryOptions): Promise { try { const result = await safeInvoke('list_directory', { path: normalizePath(path), - includeHidden: false + includeHidden: false, + respectGitignore: options?.respectGitignore ?? false, }, { timeout: 10000, onCancel: () => { @@ -134,6 +140,28 @@ export const createDesktopFilesAPI = (): FilesAPI => ({ } }, + async readFileBinary(path: string): Promise { + try { + const normalizedPath = normalizePath(path); + const result = await safeInvoke('read_file_binary', { + path: normalizedPath + }, { + timeout: 15000, + onCancel: () => { + console.warn('[FilesAPI] Read binary file operation timed out'); + } + }); + + return { + dataUrl: result?.dataUrl ?? '', + path: result?.path ? normalizePath(result.path) : normalizedPath, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(message || 'Failed to read file'); + } + }, + async writeFile(path: string, content: string): Promise<{ success: boolean; path: string }> { try { const normalizedPath = normalizePath(path); diff --git a/packages/ui/src/components/layout/Header.tsx b/packages/ui/src/components/layout/Header.tsx index c8c54fd6..2b6f2c76 100644 --- a/packages/ui/src/components/layout/Header.tsx +++ b/packages/ui/src/components/layout/Header.tsx @@ -5,7 +5,7 @@ import { TooltipTrigger, } from '@/components/ui/tooltip'; -import { RiChat4Line, RiCodeLine, RiCommandLine, RiGitBranchLine, RiLayoutLeftLine, RiPlayListAddLine, RiQuestionLine, RiSettings3Line, RiTerminalBoxLine, type RemixiconComponentType } from '@remixicon/react'; +import { RiChat4Line, RiCodeLine, RiCommandLine, RiFolder6Line, RiGitBranchLine, RiLayoutLeftLine, RiPlayListAddLine, RiQuestionLine, RiSettings3Line, RiTerminalBoxLine, type RemixiconComponentType } from '@remixicon/react'; import { useUIStore, type MainTab } from '@/stores/useUIStore'; import { useUpdateStore } from '@/stores/useUpdateStore'; import { useConfigStore } from '@/stores/useConfigStore'; @@ -266,6 +266,7 @@ export const Header: React.FC = () => { icon: RiCodeLine, badge: !isMobile && diffFileCount > 0 ? diffFileCount : undefined, }, + { id: 'files', label: 'Files', icon: RiFolder6Line }, { id: 'terminal', label: 'Terminal', icon: RiTerminalBoxLine }, { id: 'git', diff --git a/packages/ui/src/components/layout/MainLayout.tsx b/packages/ui/src/components/layout/MainLayout.tsx index daa28eb3..adf750dc 100644 --- a/packages/ui/src/components/layout/MainLayout.tsx +++ b/packages/ui/src/components/layout/MainLayout.tsx @@ -16,7 +16,7 @@ import { useDeviceInfo } from '@/lib/device'; import { useEdgeSwipe } from '@/hooks/useEdgeSwipe'; import { cn } from '@/lib/utils'; -import { ChatView, GitView, DiffView, TerminalView, SettingsView } from '@/components/views'; +import { ChatView, GitView, DiffView, TerminalView, FilesView, SettingsView } from '@/components/views'; export const MainLayout: React.FC = () => { const { @@ -306,6 +306,8 @@ export const MainLayout: React.FC = () => { return ; case 'terminal': return ; + case 'files': + return ; default: return null; } diff --git a/packages/ui/src/components/ui/HelpDialog.tsx b/packages/ui/src/components/ui/HelpDialog.tsx index 5c3567b6..894cbd0e 100644 --- a/packages/ui/src/components/ui/HelpDialog.tsx +++ b/packages/ui/src/components/ui/HelpDialog.tsx @@ -17,6 +17,7 @@ import { RiCloseCircleLine, RiCodeLine, RiCommandLine, + RiFolder6Line, RiGitBranchLine, RiLayoutLeftLine, RiPaletteLine, @@ -161,11 +162,16 @@ export const HelpDialog: React.FC = () => { }, { keys: [`${mod} + 3`], + description: "Open Files", + icon: RiFolder6Line, + }, + { + keys: [`${mod} + 4`], description: "Open Terminal", icon: RiTerminalBoxLine, }, { - keys: [`${mod} + 4`], + keys: [`${mod} + 5`], description: "Open Git Panel", icon: RiGitBranchLine, }, diff --git a/packages/ui/src/components/ui/switch.tsx b/packages/ui/src/components/ui/switch.tsx index f7b9bff0..815720cd 100644 --- a/packages/ui/src/components/ui/switch.tsx +++ b/packages/ui/src/components/ui/switch.tsx @@ -9,16 +9,18 @@ const Switch = React.forwardRef< >(({ className, ...props }, ref) => ( )); diff --git a/packages/ui/src/components/views/FilesView.tsx b/packages/ui/src/components/views/FilesView.tsx new file mode 100644 index 00000000..ce2f4ec6 --- /dev/null +++ b/packages/ui/src/components/views/FilesView.tsx @@ -0,0 +1,1240 @@ +import React from 'react'; +import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; +import createElement from 'react-syntax-highlighter/create-element'; +import { + RiArrowLeftSLine, + RiClipboardLine, + RiCloseLine, + RiCodeLine, + RiFileImageLine, + RiFileTextLine, + RiFolder3Fill, + RiFolderOpenFill, + RiLoader4Line, + RiRefreshLine, + RiSearchLine, + RiSendPlane2Line, + RiTextWrap, +} from '@remixicon/react'; +import { toast } from 'sonner'; + +import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { Textarea } from '@/components/ui/textarea'; +import { useDebouncedValue } from '@/hooks/useDebouncedValue'; +import { useFileSearchStore } from '@/stores/useFileSearchStore'; +import { useDeviceInfo } from '@/lib/device'; +import { cn, getModifierLabel } from '@/lib/utils'; +import { getLanguageFromExtension, getImageMimeType, isImageFile } from '@/lib/toolHelpers'; +import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; +import { useThemeSystem } from '@/contexts/useThemeSystem'; +import { generateSyntaxTheme } from '@/lib/theme/syntaxThemeGenerator'; +import { useSessionStore } from '@/stores/useSessionStore'; +import { useConfigStore } from '@/stores/useConfigStore'; +import { useContextStore } from '@/stores/contextStore'; +import { useUIStore } from '@/stores/useUIStore'; +import { useDirectoryStore } from '@/stores/useDirectoryStore'; +import { opencodeClient } from '@/lib/opencode/client'; + +type FileNode = { + name: string; + path: string; + type: 'file' | 'directory'; + extension?: string; + relativePath?: string; +}; + +type SelectedLineRange = { + start: number; + end: number; +}; + +const sortNodes = (items: FileNode[]) => + items.slice().sort((a, b) => { + if (a.type !== b.type) { + return a.type === 'directory' ? -1 : 1; + } + return a.name.localeCompare(b.name); + }); + +const normalizePath = (value: string): string => value.replace(/\\/g, '/'); + +const DEFAULT_IGNORED_DIR_NAMES = new Set(['node_modules']); + +const shouldIgnoreEntryName = (name: string): boolean => DEFAULT_IGNORED_DIR_NAMES.has(name); + +const shouldIgnorePath = (path: string): boolean => { + const normalized = normalizePath(path); + return normalized === 'node_modules' || normalized.endsWith('/node_modules') || normalized.includes('/node_modules/'); +}; + +const useEffectiveDirectory = () => { + const { currentSessionId, sessions, worktreeMetadata: worktreeMap } = useSessionStore(); + const { currentDirectory: fallbackDirectory } = useDirectoryStore(); + + const worktreeMetadata = currentSessionId ? worktreeMap.get(currentSessionId) ?? undefined : undefined; + const currentSession = sessions.find((session) => session.id === currentSessionId); + type SessionWithDirectory = { directory?: string }; + const sessionDirectory = (currentSession as unknown as SessionWithDirectory | undefined)?.directory; + + return worktreeMetadata?.path ?? sessionDirectory ?? fallbackDirectory ?? ''; +}; + +const MAX_HIGHLIGHT_CHARS = 200_000; +const MAX_VIEW_CHARS = 200_000; + +const CODE_EXTENSIONS = new Set([ + // JavaScript/TypeScript + 'js', 'jsx', 'ts', 'tsx', 'mjs', 'cjs', 'mts', 'cts', + // Web + 'html', 'htm', 'xhtml', 'css', 'scss', 'sass', 'less', 'styl', 'stylus', + 'vue', 'svelte', 'astro', + // Shell/Scripts + 'sh', 'bash', 'zsh', 'fish', 'ps1', 'psm1', 'bat', 'cmd', + // Python + 'py', 'pyw', 'pyx', 'pxd', 'pxi', + // Ruby + 'rb', 'erb', 'rake', 'gemspec', + // PHP + 'php', 'phtml', 'php3', 'php4', 'php5', 'phps', + // Java/JVM + 'java', 'kt', 'kts', 'scala', 'sc', 'groovy', 'gradle', + // C/C++/Objective-C + 'c', 'h', 'cpp', 'cc', 'cxx', 'hpp', 'hxx', 'hh', 'm', 'mm', + // C#/F#/.NET + 'cs', 'fs', 'fsx', 'fsi', + // Go + 'go', + // Rust + 'rs', + // Swift + 'swift', + // Dart + 'dart', + // Lua + 'lua', + // Perl + 'pl', 'pm', 'pod', + // R + 'r', 'R', 'rmd', + // Julia + 'jl', + // Haskell + 'hs', 'lhs', + // Elixir/Erlang + 'ex', 'exs', 'erl', 'hrl', + // Clojure + 'clj', 'cljs', 'cljc', 'edn', + // Lisp/Scheme + 'lisp', 'cl', 'el', 'scm', 'ss', 'rkt', + // OCaml/ReasonML + 'ml', 'mli', 're', 'rei', + // Nim + 'nim', + // Zig + 'zig', + // V + 'v', + // Crystal + 'cr', + // Kotlin Script + 'main.kts', + // SQL + 'sql', 'psql', 'plsql', + // GraphQL + 'graphql', 'gql', + // Solidity + 'sol', + // Assembly + 'asm', 's', 'S', + // Makefile variants + 'mk', + // Nix + 'nix', + // Terraform + 'tf', 'tfvars', + // Puppet + 'pp', + // Ansible + 'ansible', +]); + +const DATA_EXTENSIONS = new Set([ + // JSON variants + 'json', 'jsonc', 'json5', 'jsonl', 'ndjson', 'geojson', + // YAML + 'yaml', 'yml', + // TOML + 'toml', + // XML variants + 'xml', 'xsl', 'xslt', 'xsd', 'dtd', 'plist', + // Config files + 'ini', 'cfg', 'conf', 'config', 'env', 'properties', + // CSV/TSV + 'csv', 'tsv', + // Lock files + 'lock', +]); + +const IMAGE_EXTENSIONS = new Set([ + 'png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'ico', 'icns', + 'bmp', 'tiff', 'tif', 'psd', 'ai', 'eps', 'raw', 'cr2', 'nef', + 'heic', 'heif', 'avif', 'jxl', +]); + +const DOCUMENT_EXTENSIONS = new Set([ + // Markdown + 'md', 'mdx', 'markdown', 'mdown', 'mkd', + // Text + 'txt', 'text', 'rtf', + // Docs + 'doc', 'docx', 'odt', 'pdf', + // ReStructuredText + 'rst', + // AsciiDoc + 'adoc', 'asciidoc', + // Org + 'org', + // LaTeX + 'tex', 'latex', 'bib', +]); + +const getFileIcon = (extension?: string): React.ReactNode => { + const ext = extension?.toLowerCase(); + + if (ext && CODE_EXTENSIONS.has(ext)) { + return ; + } + if (ext && DATA_EXTENSIONS.has(ext)) { + return ; + } + if (ext && IMAGE_EXTENSIONS.has(ext)) { + return ; + } + if (ext && DOCUMENT_EXTENSIONS.has(ext)) { + return ; + } + return ; +}; + +export const FilesView: React.FC = () => { + const { files, runtime } = useRuntimeAPIs(); + const { currentTheme } = useThemeSystem(); + const syntaxTheme = React.useMemo(() => generateSyntaxTheme(currentTheme), [currentTheme]); + const { isMobile } = useDeviceInfo(); + + const currentDirectory = useEffectiveDirectory(); + const root = normalizePath(currentDirectory); + const searchFiles = useFileSearchStore((state) => state.searchFiles); + + const [searchQuery, setSearchQuery] = React.useState(''); + const debouncedSearchQuery = useDebouncedValue(searchQuery, 200); + const searchInputRef = React.useRef(null); + + const [showMobilePageContent, setShowMobilePageContent] = React.useState(false); + const [wrapLines, setWrapLines] = React.useState(isMobile); + + const [expandedDirs, setExpandedDirs] = React.useState>(new Set()); + const [childrenByDir, setChildrenByDir] = React.useState>({}); + const loadedDirsRef = React.useRef>(new Set()); + const inFlightDirsRef = React.useRef>(new Set()); + + const [searchResults, setSearchResults] = React.useState([]); + const [searching, setSearching] = React.useState(false); + + const [selectedFile, setSelectedFile] = React.useState(null); + const [fileContent, setFileContent] = React.useState(''); + const [fileLoading, setFileLoading] = React.useState(false); + const [fileError, setFileError] = React.useState(null); + const [desktopImageSrc, setDesktopImageSrc] = React.useState(''); + + // Line selection state for commenting + const [lineSelection, setLineSelection] = React.useState(null); + const [commentText, setCommentText] = React.useState(''); + const isSelectingRef = React.useRef(false); + const selectionStartRef = React.useRef(null); + + // Session/config for sending comments + const sendMessage = useSessionStore((state) => state.sendMessage); + const currentSessionId = useSessionStore((state) => state.currentSessionId); + const { currentProviderId, currentModelId, currentAgentName, currentVariant } = useConfigStore(); + const getSessionAgentSelection = useContextStore((state) => state.getSessionAgentSelection); + const getAgentModelForSession = useContextStore((state) => state.getAgentModelForSession); + const getAgentModelVariantForSession = useContextStore((state) => state.getAgentModelVariantForSession); + const setActiveMainTab = useUIStore((state) => state.setActiveMainTab); + const { inputBarOffset, isKeyboardOpen } = useUIStore(); + + // Line selection handlers + const handleLineClick = React.useCallback((lineNumber: number, shiftKey: boolean) => { + if (shiftKey && lineSelection) { + // Extend selection with shift+click + const newStart = Math.min(lineSelection.start, lineNumber); + const newEnd = Math.max(lineSelection.end, lineNumber); + setLineSelection({ start: newStart, end: newEnd }); + } else { + // Start new selection + setLineSelection({ start: lineNumber, end: lineNumber }); + } + }, [lineSelection]); + + const handleLineMouseDown = React.useCallback((lineNumber: number, e: React.MouseEvent) => { + e.preventDefault(); // Prevent text selection while selecting lines + if (e.shiftKey && lineSelection) { + // Shift+click extends selection + handleLineClick(lineNumber, true); + return; + } + isSelectingRef.current = true; + selectionStartRef.current = lineNumber; + setLineSelection({ start: lineNumber, end: lineNumber }); + }, [handleLineClick, lineSelection]); + + const handleLineMouseEnter = React.useCallback((lineNumber: number) => { + if (!isSelectingRef.current || selectionStartRef.current === null) return; + const start = Math.min(selectionStartRef.current, lineNumber); + const end = Math.max(selectionStartRef.current, lineNumber); + setLineSelection({ start, end }); + }, []); + + const handleLineMouseUp = React.useCallback(() => { + isSelectingRef.current = false; + }, []); + + // Mobile: tap to extend selection + const handleLineTap = React.useCallback((lineNumber: number) => { + if (lineSelection) { + // Extend selection to tapped line + const newStart = Math.min(lineSelection.start, lineSelection.end, lineNumber); + const newEnd = Math.max(lineSelection.start, lineSelection.end, lineNumber); + if (lineNumber < lineSelection.start || lineNumber > lineSelection.end) { + setLineSelection({ start: newStart, end: newEnd }); + return; + } + } + setLineSelection({ start: lineNumber, end: lineNumber }); + }, [lineSelection]); + + // Global mouseup to end drag selection + React.useEffect(() => { + const handleGlobalMouseUp = () => { + isSelectingRef.current = false; + }; + document.addEventListener('mouseup', handleGlobalMouseUp); + return () => document.removeEventListener('mouseup', handleGlobalMouseUp); + }, []); + + // Clear selection when file changes + React.useEffect(() => { + setLineSelection(null); + setCommentText(''); + }, [selectedFile?.path]); + + // Click outside to dismiss selection + React.useEffect(() => { + if (!lineSelection) return; + + const handleClickOutside = (e: MouseEvent) => { + const target = e.target as HTMLElement; + + // Check if click is inside comment UI + const commentUI = document.querySelector('[data-comment-ui]'); + if (commentUI?.contains(target)) return; + + // Check if click is on a line number (only line numbers should not dismiss) + if (target.closest('[data-line-number]')) return; + + // Check if click is inside toast (sonner) + if (target.closest('[data-sonner-toast]') || target.closest('[data-sonner-toaster]')) return; + + // Clicking anywhere else (including code content) dismisses selection + setLineSelection(null); + setCommentText(''); + }; + + const timeoutId = setTimeout(() => { + document.addEventListener('click', handleClickOutside); + }, 100); + + return () => { + clearTimeout(timeoutId); + document.removeEventListener('click', handleClickOutside); + }; + }, [lineSelection]); + + // Extract selected code + const extractSelectedCode = React.useCallback((content: string, range: SelectedLineRange): string => { + const lines = content.split('\n'); + const startLine = Math.max(1, range.start); + const endLine = Math.min(lines.length, range.end); + if (startLine > endLine) return ''; + return lines.slice(startLine - 1, endLine).join('\n'); + }, []); + + // Send comment handler + const handleSendComment = React.useCallback(async () => { + if (!lineSelection || !commentText.trim() || !selectedFile) return; + if (!currentSessionId) { + toast.error('Select a session to send comment'); + return; + } + + // Get session-specific agent/model/variant with fallback to config values + const sessionAgent = getSessionAgentSelection(currentSessionId) || currentAgentName; + const sessionModel = sessionAgent ? getAgentModelForSession(currentSessionId, sessionAgent) : null; + const effectiveProviderId = sessionModel?.providerId || currentProviderId; + const effectiveModelId = sessionModel?.modelId || currentModelId; + + if (!effectiveProviderId || !effectiveModelId) { + toast.error('Select a model to send comment'); + return; + } + + const effectiveVariant = sessionAgent && effectiveProviderId && effectiveModelId + ? getAgentModelVariantForSession(currentSessionId, sessionAgent, effectiveProviderId, effectiveModelId) ?? currentVariant + : currentVariant; + + const code = extractSelectedCode(fileContent, lineSelection); + const language = getLanguageFromExtension(selectedFile.path) || 'text'; + const fileName = selectedFile.name; + const startLine = lineSelection.start; + const endLine = lineSelection.end; + + const message = `Comment on \`${fileName}\` lines ${startLine}-${endLine}:\n\`\`\`${language}\n${code}\n\`\`\`\n\n${commentText}`; + + // Clear state and switch to chat immediately + setCommentText(''); + setLineSelection(null); + setActiveMainTab('chat'); + + try { + await sendMessage( + message, + effectiveProviderId, + effectiveModelId, + sessionAgent, + undefined, + undefined, + undefined, + effectiveVariant + ); + } catch (e) { + console.error('Failed to send comment', e); + } + }, [lineSelection, commentText, selectedFile, fileContent, currentSessionId, currentProviderId, currentModelId, currentAgentName, currentVariant, extractSelectedCode, sendMessage, setActiveMainTab, getSessionAgentSelection, getAgentModelForSession, getAgentModelVariantForSession]); + + const mapDirectoryEntries = React.useCallback((dirPath: string, entries: Array<{ name: string; path: string; isDirectory: boolean }>): FileNode[] => { + const nodes = entries + .filter((entry) => entry && typeof entry.name === 'string' && entry.name.length > 0) + .filter((entry) => !entry.name.startsWith('.')) + .filter((entry) => !shouldIgnoreEntryName(entry.name)) + .map((entry) => { + const name = entry.name; + const path = normalizePath(entry.path || `${dirPath}/${name}`); + const type = entry.isDirectory ? 'directory' : 'file'; + const extension = type === 'file' && name.includes('.') ? name.split('.').pop()?.toLowerCase() : undefined; + return { + name, + path, + type, + extension, + }; + }); + + return sortNodes(nodes); + }, []); + + const loadDirectory = React.useCallback(async (dirPath: string) => { + const normalizedDir = normalizePath(dirPath.trim()); + if (!normalizedDir) { + return; + } + + if (loadedDirsRef.current.has(normalizedDir) || inFlightDirsRef.current.has(normalizedDir)) { + return; + } + + inFlightDirsRef.current = new Set(inFlightDirsRef.current); + inFlightDirsRef.current.add(normalizedDir); + + try { + // Use gitignore filtering for both desktop and web + let entries: Array<{ name: string; path: string; isDirectory: boolean }>; + if (runtime.isDesktop) { + const result = await files.listDirectory(normalizedDir, { respectGitignore: true }); + entries = result.entries.map((entry) => ({ + name: entry.name, + path: entry.path, + isDirectory: entry.isDirectory, + })); + } else { + const result = await opencodeClient.listLocalDirectory(normalizedDir, { respectGitignore: true }); + entries = result.map((entry) => ({ + name: entry.name, + path: entry.path, + isDirectory: entry.isDirectory, + })); + } + + const mapped = mapDirectoryEntries(normalizedDir, entries); + + loadedDirsRef.current = new Set(loadedDirsRef.current); + loadedDirsRef.current.add(normalizedDir); + setChildrenByDir((prev) => ({ ...prev, [normalizedDir]: mapped })); + } catch { + setChildrenByDir((prev) => ({ + ...prev, + [normalizedDir]: prev[normalizedDir] ?? [], + })); + } finally { + inFlightDirsRef.current = new Set(inFlightDirsRef.current); + inFlightDirsRef.current.delete(normalizedDir); + } + }, [files, mapDirectoryEntries, runtime.isDesktop]); + + const refreshRoot = React.useCallback(async () => { + const normalizedRoot = normalizePath(currentDirectory.trim()); + if (!normalizedRoot) { + return; + } + + loadedDirsRef.current = new Set(); + inFlightDirsRef.current = new Set(); + setExpandedDirs(new Set()); + setChildrenByDir({}); + + await loadDirectory(normalizedRoot); + }, [currentDirectory, loadDirectory]); + + + + React.useEffect(() => { + if (!currentDirectory) { + return; + } + + void refreshRoot(); + setSelectedFile(null); + setFileContent(''); + setFileError(null); + setDesktopImageSrc(''); + setShowMobilePageContent(false); + }, [currentDirectory, refreshRoot]); + + const fuzzyScore = React.useCallback((query: string, candidate: string): number | null => { + const q = query.trim().toLowerCase(); + if (!q) { + return 0; + } + + const c = candidate.toLowerCase(); + let score = 0; + let lastIndex = -1; + let consecutive = 0; + + for (let i = 0; i < q.length; i += 1) { + const ch = q[i]; + if (!ch || ch === ' ') { + continue; + } + + const idx = c.indexOf(ch, lastIndex + 1); + if (idx === -1) { + return null; + } + + const gap = idx - lastIndex - 1; + if (gap === 0) { + consecutive += 1; + } else { + consecutive = 0; + } + + score += 10; + score += Math.max(0, 18 - idx); + score -= Math.max(0, gap); + + if (idx === 0) { + score += 12; + } else { + const prev = c[idx - 1]; + if (prev === '/' || prev === '_' || prev === '-' || prev === '.' || prev === ' ') { + score += 10; + } + } + + score += consecutive > 0 ? 12 : 0; + lastIndex = idx; + } + + score += Math.max(0, 24 - Math.round(c.length / 3)); + return score; + }, []); + + React.useEffect(() => { + if (!currentDirectory) { + setSearchResults([]); + setSearching(false); + return; + } + + const trimmedQuery = debouncedSearchQuery.trim(); + if (!trimmedQuery) { + setSearchResults([]); + setSearching(false); + return; + } + + const normalizedQueryLower = trimmedQuery.toLowerCase(); + let cancelled = false; + setSearching(true); + + searchFiles(currentDirectory, trimmedQuery, 150) + .then((hits) => { + if (cancelled) { + return; + } + + const filtered = hits.filter((hit) => !shouldIgnorePath(hit.path)); + + // Apply fuzzy scoring and sort by score + const ranked = filtered + .map((hit) => { + const label = hit.relativePath || hit.name || hit.path; + const score = fuzzyScore(normalizedQueryLower, label); + return score === null ? null : { hit, score, labelLength: label.length }; + }) + .filter(Boolean) as Array<{ hit: typeof hits[0]; score: number; labelLength: number }>; + + ranked.sort((a, b) => ( + b.score - a.score + || a.labelLength - b.labelLength + || a.hit.path.localeCompare(b.hit.path) + )); + + const mapped: FileNode[] = ranked.map(({ hit }) => ({ + name: hit.name, + path: normalizePath(hit.path), + type: 'file', + extension: hit.extension, + relativePath: hit.relativePath, + })); + + setSearchResults(mapped); + }) + .catch(() => { + if (!cancelled) { + setSearchResults([]); + } + }) + .finally(() => { + if (!cancelled) { + setSearching(false); + } + }); + + return () => { + cancelled = true; + }; + }, [currentDirectory, debouncedSearchQuery, fuzzyScore, searchFiles]); + + const readFile = React.useCallback(async (path: string): Promise => { + if (files.readFile) { + const result = await files.readFile(path); + return result.content ?? ''; + } + + const response = await fetch(`/api/fs/read?path=${encodeURIComponent(path)}`); + if (!response.ok) { + const error = await response.json().catch(() => ({ error: response.statusText })); + throw new Error((error as { error?: string }).error || 'Failed to read file'); + } + return response.text(); + }, [files]); + + const handleSelectFile = React.useCallback(async (node: FileNode) => { + setSelectedFile(node); + setFileError(null); + setDesktopImageSrc(''); + + const selectedIsImage = isImageFile(node.path); + + if (isMobile) { + setShowMobilePageContent(true); + } + + const isSvg = node.path.toLowerCase().endsWith('.svg'); + + // Desktop: binary images are loaded via readFileBinary (data URL). + if (runtime.isDesktop && selectedIsImage && !isSvg) { + setFileContent(''); + setFileLoading(true); + return; + } + + // Web: binary images should not be read as utf8. + if (!runtime.isDesktop && selectedIsImage && !isSvg) { + setFileContent(''); + setFileLoading(false); + return; + } + + setFileLoading(true); + + try { + const content = await readFile(node.path); + setFileContent(content); + } catch (error) { + setFileContent(''); + setFileError(error instanceof Error ? error.message : 'Failed to read file'); + } finally { + setFileLoading(false); + } + }, [isMobile, readFile, runtime.isDesktop]); + + const toggleDirectory = React.useCallback(async (dirPath: string) => { + const normalized = normalizePath(dirPath); + setExpandedDirs((prev) => { + const next = new Set(prev); + if (next.has(normalized)) { + next.delete(normalized); + } else { + next.add(normalized); + } + return next; + }); + + if (!loadedDirsRef.current.has(normalized)) { + await loadDirectory(normalized); + } + }, [loadDirectory]); + + const renderTree = React.useCallback((dirPath: string, depth: number): React.ReactNode => { + const nodes = childrenByDir[dirPath] ?? []; + + return nodes.map((node) => { + const isDir = node.type === 'directory'; + const isExpanded = isDir && expandedDirs.has(node.path); + const isActive = selectedFile?.path === node.path; + const isLoading = isDir && inFlightDirsRef.current.has(node.path); + + return ( +
  • + + {isDir && isExpanded && ( +
      + {renderTree(node.path, depth + 1)} +
    + )} +
  • + ); + }); + }, [childrenByDir, expandedDirs, handleSelectFile, selectedFile?.path, toggleDirectory]); + + const viewerLanguage = selectedFile?.path ? getLanguageFromExtension(selectedFile.path) || 'text' : 'text'; + const contentForViewer = fileContent.length > MAX_VIEW_CHARS + ? `${fileContent.slice(0, MAX_VIEW_CHARS)}\n\n… truncated …` + : fileContent; + const shouldHighlight = contentForViewer.length <= MAX_HIGHLIGHT_CHARS; + const isSelectedImage = Boolean(selectedFile?.path && isImageFile(selectedFile.path)); + const isSelectedSvg = Boolean(selectedFile?.path && selectedFile.path.toLowerCase().endsWith('.svg')); + const displaySelectedPath = React.useMemo(() => { + if (!selectedFile?.path) return ''; + const normalizedFilePath = normalizePath(selectedFile.path); + if (root && normalizedFilePath.startsWith(root)) { + const relative = normalizedFilePath.slice(root.length); + return relative.startsWith('/') ? relative.slice(1) : relative; + } + return normalizedFilePath; + }, [selectedFile?.path, root]); + + + + const canCopy = Boolean(selectedFile && (!isSelectedImage || isSelectedSvg) && fileContent.length > 0); + + const imageSrc = selectedFile?.path && isSelectedImage + ? (runtime.isDesktop + ? (isSelectedSvg + ? `data:${getImageMimeType(selectedFile.path)};utf8,${encodeURIComponent(fileContent)}` + : desktopImageSrc) + : (isSelectedSvg + ? `data:${getImageMimeType(selectedFile.path)};utf8,${encodeURIComponent(fileContent)}` + : `/api/fs/raw?path=${encodeURIComponent(selectedFile.path)}`)) + : ''; + + const codeRenderer = React.useCallback(({ + rows, + stylesheet, + useInlineStyles, + }: { + rows: unknown[]; + stylesheet: unknown; + useInlineStyles: boolean; + }) => { + const gutterWidthCh = Math.max(3, String(rows.length).length + 1); + + return ( +
    + {rows.map((row, index) => { + const lineNumber = index + 1; + const isSelected = lineSelection !== null && lineNumber >= lineSelection.start && lineNumber <= lineSelection.end; + + return ( +
    handleLineMouseEnter(lineNumber)} + style={{ + display: 'flex', + alignItems: 'flex-start', + lineHeight: '1.5rem', + position: 'relative', + backgroundColor: isSelected ? 'color-mix(in srgb, var(--accent) 70%, transparent)' : undefined, + }} + > + handleLineMouseDown(lineNumber, e)} + onMouseUp={isMobile ? undefined : handleLineMouseUp} + onClick={isMobile ? () => handleLineTap(lineNumber) : undefined} + style={{ + width: `calc(${gutterWidthCh}ch + 0.75rem + 0.75rem)`, + flexShrink: 0, + paddingLeft: '0.75rem', + paddingRight: '1.75ch', + textAlign: 'right', + color: 'hsl(var(--muted-foreground))', + opacity: 0.35, + fontSize: '0.8em', + lineHeight: '1.5rem', + userSelect: 'none', + WebkitUserSelect: 'none', + MozUserSelect: 'none', + msUserSelect: 'none' as const, + cursor: 'pointer', + touchAction: 'manipulation', + }} + > + {lineNumber} + + + {createElement({ node: row, stylesheet, useInlineStyles, key: index })} + +
    + ); + })} +
    + ); + }, [wrapLines, lineSelection, isMobile, handleLineMouseDown, handleLineMouseEnter, handleLineMouseUp, handleLineTap]); + + + React.useEffect(() => { + let cancelled = false; + + const resolveDesktopImage = async () => { + if (!runtime.isDesktop || !selectedFile?.path || !isSelectedImage || isSelectedSvg) { + setDesktopImageSrc(''); + return; + } + + setFileError(null); + + try { + if (files.readFileBinary) { + const result = await files.readFileBinary(selectedFile.path); + if (!cancelled) { + setDesktopImageSrc(result.dataUrl); + } + return; + } + + const core = await import('@tauri-apps/api/core'); + const convertFileSrc = (core as { convertFileSrc?: (path: string, protocol?: string) => string }).convertFileSrc; + if (!convertFileSrc) { + return; + } + + const src = convertFileSrc(selectedFile.path, 'asset'); + if (!cancelled) { + setDesktopImageSrc(src); + } + } catch (error) { + if (!cancelled) { + setDesktopImageSrc(''); + setFileError(error instanceof Error ? error.message : 'Failed to read file'); + } + } finally { + if (!cancelled) { + setFileLoading(false); + } + } + }; + + void resolveDesktopImage(); + + return () => { + cancelled = true; + }; + }, [files, isSelectedImage, isSelectedSvg, runtime.isDesktop, selectedFile?.path]); + + // Comment UI component + const renderCommentUI = () => { + if (!lineSelection || !selectedFile) return null; + return ( +
    +
    +