feat: add Files tab for browsing workspace files (#154)
* feat: add Files tab for browsing workspace files - Add Files tab between Diff and Terminal in header - Implement hierarchical file tree with expand/collapse - Add fuzzy search with debouncing and relevance ranking - Support gitignore filtering via `git check-ignore` (web + desktop) - Add syntax highlighting for 150+ file types - Add image preview (SVG, PNG, JPG, etc.) - Add line numbers, wrap toggle, and copy button - Desktop: split-pane layout matching DiffView - Mobile: drill-in navigation with full-width sidebar - Update help dialog with Cmd+3 shortcut - Increase header breakpoint to 940px for new tab * feat: enhance context and session stores to track agent/model/variant choices for historical sessions * feat: implement line selection and commenting functionality in FilesView
This commit is contained in:
committed by
GitHub
parent
ecf81c901d
commit
1be5dfda05
@@ -135,6 +135,7 @@ impl From<std::io::Error> for FsCommandError {
|
||||
#[tauri::command]
|
||||
pub async fn list_directory(
|
||||
path: Option<String>,
|
||||
respect_gitignore: Option<bool>,
|
||||
state: tauri::State<'_, DesktopRuntime>,
|
||||
) -> Result<DirectoryListResult, String> {
|
||||
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<String> = if respect_gitignore.unwrap_or(false) {
|
||||
let names: Vec<String> = 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<ReadFileBinaryResponse, String> {
|
||||
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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<DirectoryListResult> {
|
||||
async listDirectory(path: string, options?: ListDirectoryOptions): Promise<DirectoryListResult> {
|
||||
try {
|
||||
const result = await safeInvoke<ListDirectoryResponse>('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<ReadFileBinaryResponse> {
|
||||
try {
|
||||
const normalizedPath = normalizePath(path);
|
||||
const result = await safeInvoke<ReadFileBinaryResponse>('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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 <DiffView />;
|
||||
case 'terminal':
|
||||
return <TerminalView />;
|
||||
case 'files':
|
||||
return <FilesView />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -9,16 +9,18 @@ const Switch = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
'peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input',
|
||||
'peer inline-flex shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-muted-foreground/30',
|
||||
className
|
||||
)}
|
||||
style={{ width: '36px', height: '20px', minWidth: '36px', minHeight: '20px' }}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
'pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0'
|
||||
'pointer-events-none block rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0'
|
||||
)}
|
||||
style={{ width: '16px', height: '16px', minWidth: '16px', minHeight: '16px' }}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
));
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,9 +9,11 @@ import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
import { ensureFlexokiThemesRegistered } from '@/lib/shiki/registerFlexokiThemes';
|
||||
import { flexokiThemeNames } from '@/lib/shiki/flexokiThemes';
|
||||
|
||||
import { toast } from 'sonner';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { useSessionStore } from '@/stores/useSessionStore';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { useContextStore } from '@/stores/contextStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useDeviceInfo } from '@/lib/device';
|
||||
import { cn, getModifierLabel } from '@/lib/utils';
|
||||
@@ -158,6 +160,9 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
|
||||
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);
|
||||
|
||||
// Update main content center on resize
|
||||
useEffect(() => {
|
||||
@@ -210,6 +215,9 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
|
||||
// Check if click is inside the comment UI portal
|
||||
const commentUI = document.querySelector('[data-comment-ui]');
|
||||
if (commentUI?.contains(target)) return;
|
||||
|
||||
// Check if click is inside toast (sonner)
|
||||
if (target.closest('[data-sonner-toast]') || target.closest('[data-sonner-toaster]')) return;
|
||||
|
||||
// Check if click is on a line number (inside shadow DOM)
|
||||
const path = e.composedPath();
|
||||
@@ -239,10 +247,25 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
|
||||
|
||||
const handleSendComment = useCallback(async () => {
|
||||
if (!selection || !commentText.trim()) return;
|
||||
if (!currentSessionId || !currentProviderId || !currentModelId) {
|
||||
console.warn('Cannot send comment: no active session or model not selected');
|
||||
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(original, modified, selection);
|
||||
const startLine = selection.start;
|
||||
@@ -259,18 +282,18 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
|
||||
try {
|
||||
await sendMessage(
|
||||
message,
|
||||
currentProviderId,
|
||||
currentModelId,
|
||||
currentAgentName,
|
||||
effectiveProviderId,
|
||||
effectiveModelId,
|
||||
sessionAgent,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
currentVariant
|
||||
effectiveVariant
|
||||
);
|
||||
} catch (e) {
|
||||
console.error("Failed to send comment", e);
|
||||
}
|
||||
}, [selection, commentText, original, modified, fileName, language, sendMessage, currentSessionId, currentProviderId, currentModelId, currentAgentName, currentVariant, setActiveMainTab]);
|
||||
}, [selection, commentText, original, modified, fileName, language, sendMessage, currentSessionId, currentProviderId, currentModelId, currentAgentName, currentVariant, setActiveMainTab, getSessionAgentSelection, getAgentModelForSession, getAgentModelVariantForSession]);
|
||||
|
||||
ensureFlexokiThemesRegistered();
|
||||
|
||||
@@ -339,7 +362,7 @@ export const PierreDiffViewer: React.FC<PierreDiffViewerProps> = ({
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col items-center gap-2 px-4"
|
||||
style={{ width: 'min(100vw - 2rem, 42rem)' }}
|
||||
style={{ width: 'min(100vw - 1rem, 42rem)' }}
|
||||
>
|
||||
<div className="w-full rounded-xl border bg-sidebar flex flex-col relative shadow-lg" style={{ borderColor: 'var(--primary)' }}>
|
||||
{/* Textarea - auto-grows from 1 line to max 5 lines */}
|
||||
|
||||
@@ -45,7 +45,7 @@ const SETTINGS_SIDEBAR_MAX_WIDTH = 500;
|
||||
const SETTINGS_SIDEBAR_DEFAULT_WIDTH = 264;
|
||||
|
||||
// Width threshold for hiding tab labels (show icons only)
|
||||
const TAB_LABELS_MIN_WIDTH = 700;
|
||||
const TAB_LABELS_MIN_WIDTH = 940;
|
||||
|
||||
interface SettingsViewProps {
|
||||
onClose?: () => void;
|
||||
|
||||
@@ -2,4 +2,5 @@ export { ChatView } from './ChatView';
|
||||
export { GitView } from './GitView';
|
||||
export { DiffView, useDiffFileCount } from './DiffView';
|
||||
export { TerminalView } from './TerminalView';
|
||||
export { FilesView } from './FilesView';
|
||||
export { SettingsView } from './SettingsView';
|
||||
|
||||
@@ -920,7 +920,7 @@ html:not(.dark) .chat-scroll {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
@media (max-width: 820px) {
|
||||
@media (max-width: 940px) {
|
||||
:root:not(.mobile-pointer):not(.vscode-runtime) .header-tab-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -339,11 +339,16 @@ export interface CommandExecResult {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface ListDirectoryOptions {
|
||||
respectGitignore?: boolean;
|
||||
}
|
||||
|
||||
export interface FilesAPI {
|
||||
listDirectory(path: string): Promise<DirectoryListResult>;
|
||||
listDirectory(path: string, options?: ListDirectoryOptions): Promise<DirectoryListResult>;
|
||||
search(payload: FileSearchQuery): Promise<FileSearchResult[]>;
|
||||
createDirectory(path: string): Promise<{ success: boolean; path: string }>;
|
||||
readFile?(path: string): Promise<{ content: string; path: string }>;
|
||||
readFileBinary?(path: string): Promise<{ dataUrl: string; path: string }>;
|
||||
writeFile?(path: string, content: string): Promise<{ success: boolean; path: string }>;
|
||||
execCommands?(commands: string[], cwd: string): Promise<{ success: boolean; results: CommandExecResult[] }>;
|
||||
}
|
||||
|
||||
@@ -1703,7 +1703,7 @@ class OpencodeService {
|
||||
return result;
|
||||
}
|
||||
|
||||
async listLocalDirectory(directoryPath: string | null | undefined): Promise<FilesystemEntry[]> {
|
||||
async listLocalDirectory(directoryPath: string | null | undefined, options?: { respectGitignore?: boolean }): Promise<FilesystemEntry[]> {
|
||||
const desktopFiles = getDesktopFilesApi();
|
||||
if (desktopFiles) {
|
||||
try {
|
||||
@@ -1729,6 +1729,9 @@ class OpencodeService {
|
||||
if (directoryPath && directoryPath.trim().length > 0) {
|
||||
params.set('path', directoryPath);
|
||||
}
|
||||
if (options?.respectGitignore) {
|
||||
params.set('respectGitignore', 'true');
|
||||
}
|
||||
const query = params.toString();
|
||||
const response = await fetch(`${this.baseUrl}/fs/list${query ? `?${query}` : ''}`);
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -214,72 +214,409 @@ export function detectToolOutputLanguage(
|
||||
|
||||
export function getLanguageFromExtension(filePath: string): string | null {
|
||||
const ext = filePath.split('.').pop()?.toLowerCase();
|
||||
|
||||
// Handle special filenames without extensions
|
||||
const filename = filePath.split('/').pop()?.toLowerCase() || '';
|
||||
const filenameMap: Record<string, string> = {
|
||||
'dockerfile': 'dockerfile',
|
||||
'makefile': 'makefile',
|
||||
'gnumakefile': 'makefile',
|
||||
'cmakelists.txt': 'cmake',
|
||||
'gemfile': 'ruby',
|
||||
'rakefile': 'ruby',
|
||||
'podfile': 'ruby',
|
||||
'vagrantfile': 'ruby',
|
||||
'guardfile': 'ruby',
|
||||
'brewfile': 'ruby',
|
||||
'fastfile': 'ruby',
|
||||
'appfile': 'ruby',
|
||||
'matchfile': 'ruby',
|
||||
'pluginfile': 'ruby',
|
||||
'scanfile': 'ruby',
|
||||
'snapfile': 'ruby',
|
||||
'.gitignore': 'text',
|
||||
'.gitattributes': 'text',
|
||||
'.gitmodules': 'ini',
|
||||
'.editorconfig': 'ini',
|
||||
'.npmrc': 'ini',
|
||||
'.yarnrc': 'yaml',
|
||||
'.prettierrc': 'json',
|
||||
'.eslintrc': 'json',
|
||||
'.babelrc': 'json',
|
||||
'.browserslistrc': 'text',
|
||||
'tsconfig.json': 'jsonc',
|
||||
'jsconfig.json': 'jsonc',
|
||||
'.env': 'bash',
|
||||
'.env.local': 'bash',
|
||||
'.env.development': 'bash',
|
||||
'.env.production': 'bash',
|
||||
'.env.test': 'bash',
|
||||
'procfile': 'yaml',
|
||||
'codeowners': 'text',
|
||||
// Lock files
|
||||
'package-lock.json': 'json',
|
||||
'composer.lock': 'json',
|
||||
'yarn.lock': 'yaml',
|
||||
'pnpm-lock.yaml': 'yaml',
|
||||
'cargo.lock': 'toml',
|
||||
'poetry.lock': 'toml',
|
||||
'gemfile.lock': 'ruby',
|
||||
'pubspec.lock': 'yaml',
|
||||
'packages.lock.json': 'json',
|
||||
'bun.lockb': 'text',
|
||||
'bun.lock': 'json',
|
||||
};
|
||||
|
||||
if (filenameMap[filename]) {
|
||||
return filenameMap[filename];
|
||||
}
|
||||
|
||||
const languageMap: Record<string, string> = {
|
||||
|
||||
// JavaScript/TypeScript
|
||||
'js': 'javascript',
|
||||
'jsx': 'jsx',
|
||||
'ts': 'typescript',
|
||||
'tsx': 'tsx',
|
||||
'mjs': 'javascript',
|
||||
'cjs': 'javascript',
|
||||
'mts': 'typescript',
|
||||
'cts': 'typescript',
|
||||
|
||||
// Web markup/styling
|
||||
'html': 'html',
|
||||
'htm': 'html',
|
||||
'xhtml': 'html',
|
||||
'vue': 'html',
|
||||
'svelte': 'html',
|
||||
'astro': 'html',
|
||||
'ejs': 'html',
|
||||
'hbs': 'handlebars',
|
||||
'handlebars': 'handlebars',
|
||||
'mustache': 'handlebars',
|
||||
'njk': 'twig',
|
||||
'nunjucks': 'twig',
|
||||
'twig': 'twig',
|
||||
'liquid': 'liquid',
|
||||
'css': 'css',
|
||||
'scss': 'scss',
|
||||
'sass': 'sass',
|
||||
'less': 'less',
|
||||
'styl': 'stylus',
|
||||
'stylus': 'stylus',
|
||||
'pcss': 'css',
|
||||
'postcss': 'css',
|
||||
|
||||
// Data/config formats
|
||||
'json': 'json',
|
||||
'jsonc': 'json',
|
||||
'json5': 'json',
|
||||
'jsonl': 'json',
|
||||
'ndjson': 'json',
|
||||
'geojson': 'json',
|
||||
'yaml': 'yaml',
|
||||
'yml': 'yaml',
|
||||
'toml': 'toml',
|
||||
'xml': 'xml',
|
||||
'xsl': 'xml',
|
||||
'xslt': 'xml',
|
||||
'xsd': 'xml',
|
||||
'dtd': 'xml',
|
||||
'plist': 'xml',
|
||||
'svg': 'xml',
|
||||
'rss': 'xml',
|
||||
'atom': 'xml',
|
||||
'xaml': 'xml',
|
||||
'csproj': 'xml',
|
||||
'vbproj': 'xml',
|
||||
'fsproj': 'xml',
|
||||
'props': 'xml',
|
||||
'targets': 'xml',
|
||||
'nuspec': 'xml',
|
||||
'resx': 'xml',
|
||||
'ini': 'ini',
|
||||
'cfg': 'ini',
|
||||
'conf': 'ini',
|
||||
'config': 'ini',
|
||||
'properties': 'properties',
|
||||
'env': 'bash',
|
||||
'csv': 'text',
|
||||
'tsv': 'text',
|
||||
|
||||
// Python
|
||||
'py': 'python',
|
||||
'pyw': 'python',
|
||||
'pyx': 'python',
|
||||
'pxd': 'python',
|
||||
'pxi': 'python',
|
||||
'pyi': 'python',
|
||||
'gyp': 'python',
|
||||
'gypi': 'python',
|
||||
'bzl': 'python',
|
||||
|
||||
// Ruby
|
||||
'rb': 'ruby',
|
||||
'go': 'go',
|
||||
'rs': 'rust',
|
||||
'erb': 'erb',
|
||||
'rake': 'ruby',
|
||||
'gemspec': 'ruby',
|
||||
'ru': 'ruby',
|
||||
'podspec': 'ruby',
|
||||
'thor': 'ruby',
|
||||
'jbuilder': 'ruby',
|
||||
'rabl': 'ruby',
|
||||
'builder': 'ruby',
|
||||
|
||||
// PHP
|
||||
'php': 'php',
|
||||
'phtml': 'php',
|
||||
'php3': 'php',
|
||||
'php4': 'php',
|
||||
'php5': 'php',
|
||||
'php7': 'php',
|
||||
'phps': 'php',
|
||||
'inc': 'php',
|
||||
'blade.php': 'php',
|
||||
|
||||
// Java/JVM
|
||||
'java': 'java',
|
||||
'kt': 'kotlin',
|
||||
'swift': 'swift',
|
||||
'kts': 'kotlin',
|
||||
'scala': 'scala',
|
||||
'sc': 'scala',
|
||||
'groovy': 'groovy',
|
||||
'gradle': 'groovy',
|
||||
'gvy': 'groovy',
|
||||
'gy': 'groovy',
|
||||
'gsh': 'groovy',
|
||||
|
||||
// C/C++/Objective-C
|
||||
'c': 'c',
|
||||
'h': 'c',
|
||||
'cpp': 'cpp',
|
||||
'cc': 'cpp',
|
||||
'h': 'c',
|
||||
'cxx': 'cpp',
|
||||
'c++': 'cpp',
|
||||
'hpp': 'cpp',
|
||||
'cs': 'csharp',
|
||||
'php': 'php',
|
||||
'dart': 'dart',
|
||||
'r': 'r',
|
||||
'lua': 'lua',
|
||||
'vim': 'vim',
|
||||
'hxx': 'cpp',
|
||||
'hh': 'cpp',
|
||||
'h++': 'cpp',
|
||||
'ino': 'cpp',
|
||||
'm': 'objectivec',
|
||||
'mm': 'objectivec',
|
||||
|
||||
// C#/F#/.NET
|
||||
'cs': 'csharp',
|
||||
'csx': 'csharp',
|
||||
'cake': 'csharp',
|
||||
'fs': 'fsharp',
|
||||
'fsx': 'fsharp',
|
||||
'fsi': 'fsharp',
|
||||
'vb': 'vbnet',
|
||||
|
||||
// Go
|
||||
'go': 'go',
|
||||
'mod': 'go',
|
||||
'sum': 'text',
|
||||
|
||||
// Rust
|
||||
'rs': 'rust',
|
||||
|
||||
// Swift
|
||||
'swift': 'swift',
|
||||
|
||||
// Dart
|
||||
'dart': 'dart',
|
||||
|
||||
// Lua
|
||||
'lua': 'lua',
|
||||
|
||||
// Perl
|
||||
'pl': 'perl',
|
||||
'pm': 'perl',
|
||||
'pod': 'perl',
|
||||
't': 'perl',
|
||||
|
||||
// R
|
||||
'r': 'r',
|
||||
'R': 'r',
|
||||
'rmd': 'markdown',
|
||||
'rnw': 'r',
|
||||
|
||||
// Julia
|
||||
'jl': 'julia',
|
||||
|
||||
// Haskell
|
||||
'hs': 'haskell',
|
||||
'lhs': 'haskell',
|
||||
|
||||
// Elixir/Erlang
|
||||
'ex': 'elixir',
|
||||
'exs': 'elixir',
|
||||
'eex': 'elixir',
|
||||
'heex': 'elixir',
|
||||
'leex': 'elixir',
|
||||
'erl': 'erlang',
|
||||
'hrl': 'erlang',
|
||||
|
||||
// Clojure
|
||||
'clj': 'clojure',
|
||||
'cljs': 'clojure',
|
||||
'cljc': 'clojure',
|
||||
'edn': 'clojure',
|
||||
|
||||
// Lisp/Scheme
|
||||
'lisp': 'lisp',
|
||||
'cl': 'lisp',
|
||||
'el': 'lisp',
|
||||
'scm': 'scheme',
|
||||
'ss': 'scheme',
|
||||
'rkt': 'scheme',
|
||||
|
||||
// OCaml/ReasonML
|
||||
'ml': 'ocaml',
|
||||
'mli': 'ocaml',
|
||||
're': 'reason',
|
||||
'rei': 'reason',
|
||||
|
||||
// Nim
|
||||
'nim': 'nim',
|
||||
'nims': 'nim',
|
||||
'nimble': 'nim',
|
||||
|
||||
// Zig
|
||||
'zig': 'zig',
|
||||
|
||||
// V
|
||||
'v': 'v',
|
||||
'vsh': 'v',
|
||||
|
||||
// Crystal
|
||||
'cr': 'crystal',
|
||||
|
||||
// D
|
||||
'd': 'd',
|
||||
'di': 'd',
|
||||
|
||||
// Shell/Scripts
|
||||
'sh': 'bash',
|
||||
'bash': 'bash',
|
||||
'zsh': 'bash',
|
||||
'fish': 'bash',
|
||||
'ksh': 'bash',
|
||||
'csh': 'bash',
|
||||
'tcsh': 'bash',
|
||||
'ps1': 'powershell',
|
||||
'psm1': 'powershell',
|
||||
'psd1': 'powershell',
|
||||
'bat': 'batch',
|
||||
'cmd': 'batch',
|
||||
|
||||
// SQL
|
||||
'sql': 'sql',
|
||||
'psql': 'sql',
|
||||
'plsql': 'sql',
|
||||
'mysql': 'sql',
|
||||
'pgsql': 'sql',
|
||||
'sqlite': 'sql',
|
||||
|
||||
// GraphQL
|
||||
'graphql': 'graphql',
|
||||
'gql': 'graphql',
|
||||
|
||||
// Solidity
|
||||
'sol': 'solidity',
|
||||
|
||||
// Assembly
|
||||
'asm': 'nasm',
|
||||
's': 'nasm',
|
||||
'S': 'nasm',
|
||||
|
||||
// Nix
|
||||
'nix': 'nix',
|
||||
|
||||
// Terraform/HCL
|
||||
'tf': 'hcl',
|
||||
'tfvars': 'hcl',
|
||||
'hcl': 'hcl',
|
||||
|
||||
// Docker
|
||||
'dockerignore': 'text',
|
||||
|
||||
// Puppet
|
||||
'pp': 'puppet',
|
||||
|
||||
// LaTeX
|
||||
'tex': 'latex',
|
||||
'latex': 'latex',
|
||||
'sty': 'latex',
|
||||
'cls': 'latex',
|
||||
'bib': 'bibtex',
|
||||
'bst': 'bibtex',
|
||||
|
||||
// Markdown/docs
|
||||
'md': 'markdown',
|
||||
'mdx': 'markdown',
|
||||
'markdown': 'markdown',
|
||||
'mdown': 'markdown',
|
||||
'mkd': 'markdown',
|
||||
'rst': 'text',
|
||||
'adoc': 'asciidoc',
|
||||
'asciidoc': 'asciidoc',
|
||||
'org': 'text',
|
||||
'txt': 'text',
|
||||
'text': 'text',
|
||||
'rtf': 'text',
|
||||
|
||||
'dockerfile': 'dockerfile',
|
||||
'makefile': 'makefile',
|
||||
'gitignore': 'text',
|
||||
'env': 'text',
|
||||
'conf': 'text',
|
||||
'cfg': 'text',
|
||||
'ini': 'ini',
|
||||
// Vim
|
||||
'vim': 'vim',
|
||||
'vimrc': 'vim',
|
||||
|
||||
'sql': 'sql',
|
||||
// Makefile variants
|
||||
'mk': 'makefile',
|
||||
|
||||
// CMake
|
||||
'cmake': 'cmake',
|
||||
|
||||
// Diff/Patch
|
||||
'diff': 'diff',
|
||||
'patch': 'diff'
|
||||
'patch': 'diff',
|
||||
|
||||
|
||||
|
||||
// Prisma
|
||||
'prisma': 'prisma',
|
||||
|
||||
// Protocol Buffers
|
||||
'proto': 'protobuf',
|
||||
|
||||
// Thrift
|
||||
'thrift': 'thrift',
|
||||
|
||||
// WASM
|
||||
'wat': 'wasm',
|
||||
'wast': 'wasm',
|
||||
|
||||
|
||||
|
||||
// GLSL/Shaders
|
||||
'glsl': 'glsl',
|
||||
'vert': 'glsl',
|
||||
'frag': 'glsl',
|
||||
'geom': 'glsl',
|
||||
'comp': 'glsl',
|
||||
'hlsl': 'hlsl',
|
||||
'fx': 'hlsl',
|
||||
'cg': 'cg',
|
||||
'shader': 'glsl',
|
||||
|
||||
// Apache/Nginx config
|
||||
'htaccess': 'apacheconf',
|
||||
'nginx': 'nginx',
|
||||
|
||||
// Kubernetes
|
||||
'kubeconfig': 'yaml',
|
||||
|
||||
// Ansible
|
||||
'ansible': 'yaml',
|
||||
};
|
||||
|
||||
return languageMap[ext || ''] || null;
|
||||
|
||||
@@ -265,21 +265,37 @@ export const useContextStore = create<ContextStore>()(
|
||||
const allMessages = sessionMessages.filter((m: any) => m.info.role === "assistant" || m.info.role === "user").sort((a: any, b: any) => a.info.time.created - b.info.time.created);
|
||||
const assistantMessages = sessionMessages.filter((m: any) => m.info.role === "assistant").sort((a: any, b: any) => a.info.time.created - b.info.time.created);
|
||||
|
||||
// Track variant from user messages to apply to corresponding assistant response
|
||||
let pendingVariant: string | undefined = undefined;
|
||||
let pendingUserModel: { providerID: string; modelID: string } | undefined = undefined;
|
||||
|
||||
for (let messageIndex = 0; messageIndex < allMessages.length; messageIndex++) {
|
||||
const message = allMessages[messageIndex];
|
||||
const { info } = message;
|
||||
const infoAny = info as any;
|
||||
|
||||
// User messages have variant and model info in different structure
|
||||
if (infoAny.role === "user") {
|
||||
// User message: variant is top-level, model is nested in model.providerID/modelID
|
||||
pendingVariant = typeof infoAny.variant === 'string' && infoAny.variant.trim().length > 0
|
||||
? infoAny.variant
|
||||
: undefined;
|
||||
pendingUserModel = infoAny.model?.providerID && infoAny.model?.modelID
|
||||
? { providerID: infoAny.model.providerID, modelID: infoAny.model.modelID }
|
||||
: undefined;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Assistant message: providerID/modelID are top-level
|
||||
if (infoAny.providerID && infoAny.modelID) {
|
||||
const agentName = extractAgentFromMessage(infoAny, assistantMessages.indexOf(message));
|
||||
|
||||
if (agentName && agents.find((a) => a.name === agentName)) {
|
||||
const resolvedVariant = typeof infoAny.variant === 'string' && infoAny.variant.trim().length > 0
|
||||
? infoAny.variant
|
||||
: undefined;
|
||||
|
||||
if (resolvedVariant) {
|
||||
saveAgentModelVariantForSession(sessionId, agentName, infoAny.providerID, infoAny.modelID, resolvedVariant);
|
||||
// Apply pending variant from user message if model matches
|
||||
if (pendingVariant && pendingUserModel &&
|
||||
pendingUserModel.providerID === infoAny.providerID &&
|
||||
pendingUserModel.modelID === infoAny.modelID) {
|
||||
saveAgentModelVariantForSession(sessionId, agentName, infoAny.providerID, infoAny.modelID, pendingVariant);
|
||||
}
|
||||
|
||||
const choice = {
|
||||
@@ -294,6 +310,10 @@ export const useContextStore = create<ContextStore>()(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clear pending variant after processing assistant message
|
||||
pendingVariant = undefined;
|
||||
pendingUserModel = undefined;
|
||||
}
|
||||
|
||||
for (const [agentName, choice] of agentLastChoices) {
|
||||
|
||||
@@ -288,6 +288,24 @@ export const useSessionStore = create<SessionStore>()(
|
||||
}
|
||||
|
||||
get().trimToViewportWindow(id, ACTIVE_SESSION_WINDOW);
|
||||
|
||||
// Analyze session messages to extract agent/model/variant choices
|
||||
// This ensures context is available even when ModelControls isn't mounted
|
||||
const sessionMessages = get().messages.get(id);
|
||||
if (sessionMessages && sessionMessages.length > 0) {
|
||||
const agents = useConfigStore.getState().agents;
|
||||
if (agents.length > 0) {
|
||||
try {
|
||||
await useContextStore.getState().analyzeAndSaveExternalSessionChoices(
|
||||
id,
|
||||
agents,
|
||||
get().messages
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn('Failed to analyze session choices:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
get().evictLeastRecentlyUsed();
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { SidebarSection } from '@/constants/sidebar';
|
||||
import { getSafeStorage } from './utils/safeStorage';
|
||||
import { SEMANTIC_TYPOGRAPHY, getTypographyVariable, type SemanticTypographyKey } from '@/lib/typography';
|
||||
|
||||
export type MainTab = 'chat' | 'git' | 'diff' | 'terminal';
|
||||
export type MainTab = 'chat' | 'git' | 'diff' | 'terminal' | 'files';
|
||||
export type EventStreamStatus =
|
||||
| 'idle'
|
||||
| 'connecting'
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
declare module 'react-syntax-highlighter/create-element' {
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
type CreateElementOptions = {
|
||||
node: unknown;
|
||||
stylesheet: unknown;
|
||||
useInlineStyles: boolean;
|
||||
key?: string | number;
|
||||
};
|
||||
|
||||
const createElement: (options: CreateElementOptions) => ReactNode;
|
||||
|
||||
export default createElement;
|
||||
}
|
||||
@@ -4021,6 +4021,54 @@ async function main(options = {}) {
|
||||
}
|
||||
});
|
||||
|
||||
// Read file as raw bytes (images, etc.)
|
||||
app.get('/api/fs/raw', async (req, res) => {
|
||||
const filePath = typeof req.query.path === 'string' ? req.query.path.trim() : '';
|
||||
if (!filePath) {
|
||||
return res.status(400).json({ error: 'Path is required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const resolvedPath = path.resolve(normalizeDirectoryPath(filePath));
|
||||
if (resolvedPath.includes('..')) {
|
||||
return res.status(400).json({ error: 'Invalid path: path traversal not allowed' });
|
||||
}
|
||||
|
||||
const stats = await fsPromises.stat(resolvedPath);
|
||||
if (!stats.isFile()) {
|
||||
return res.status(400).json({ error: 'Specified path is not a file' });
|
||||
}
|
||||
|
||||
const ext = path.extname(resolvedPath).toLowerCase();
|
||||
const mimeMap = {
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.gif': 'image/gif',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.webp': 'image/webp',
|
||||
'.ico': 'image/x-icon',
|
||||
'.bmp': 'image/bmp',
|
||||
'.avif': 'image/avif',
|
||||
};
|
||||
const mimeType = mimeMap[ext] || 'application/octet-stream';
|
||||
|
||||
const content = await fsPromises.readFile(resolvedPath);
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
res.type(mimeType).send(content);
|
||||
} catch (error) {
|
||||
const err = error;
|
||||
if (err && typeof err === 'object' && err.code === 'ENOENT') {
|
||||
return res.status(404).json({ error: 'File not found' });
|
||||
}
|
||||
if (err && typeof err === 'object' && err.code === 'EACCES') {
|
||||
return res.status(403).json({ error: 'Access to file denied' });
|
||||
}
|
||||
console.error('Failed to read raw file:', error);
|
||||
res.status(500).json({ error: (error && error.message) || 'Failed to read file' });
|
||||
}
|
||||
});
|
||||
|
||||
// Write file contents
|
||||
app.post('/api/fs/write', async (req, res) => {
|
||||
const { path: filePath, content } = req.body || {};
|
||||
@@ -4327,6 +4375,7 @@ async function main(options = {}) {
|
||||
const rawPath = typeof req.query.path === 'string' && req.query.path.trim().length > 0
|
||||
? req.query.path.trim()
|
||||
: os.homedir();
|
||||
const respectGitignore = req.query.respectGitignore === 'true';
|
||||
|
||||
try {
|
||||
const resolvedPath = path.resolve(normalizeDirectoryPath(rawPath));
|
||||
@@ -4337,16 +4386,57 @@ async function main(options = {}) {
|
||||
}
|
||||
|
||||
const dirents = await fsPromises.readdir(resolvedPath, { withFileTypes: true });
|
||||
|
||||
// Get gitignored paths if requested
|
||||
let ignoredPaths = new Set();
|
||||
if (respectGitignore) {
|
||||
try {
|
||||
// Get all entry paths to check (relative to resolvedPath for git check-ignore)
|
||||
const pathsToCheck = dirents.map((d) => d.name);
|
||||
|
||||
if (pathsToCheck.length > 0) {
|
||||
try {
|
||||
// Use git check-ignore with paths as arguments
|
||||
// Pass paths directly as arguments (works for reasonable directory sizes)
|
||||
const result = await new Promise((resolve) => {
|
||||
const child = spawn('git', ['check-ignore', '--', ...pathsToCheck], {
|
||||
cwd: resolvedPath,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
child.stdout.on('data', (data) => { stdout += data.toString(); });
|
||||
child.on('close', () => resolve(stdout));
|
||||
child.on('error', () => resolve(''));
|
||||
});
|
||||
|
||||
result.split('\n').filter(Boolean).forEach((name) => {
|
||||
const fullPath = path.join(resolvedPath, name.trim());
|
||||
ignoredPaths.add(fullPath);
|
||||
});
|
||||
} catch {
|
||||
// git check-ignore fails if not a git repo, continue without filtering
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// If git is not available, continue without gitignore filtering
|
||||
}
|
||||
}
|
||||
|
||||
const entries = await Promise.all(
|
||||
dirents.map(async (dirent) => {
|
||||
const entryPath = path.join(resolvedPath, dirent.name);
|
||||
|
||||
// Skip gitignored entries
|
||||
if (respectGitignore && ignoredPaths.has(entryPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let isDirectory = dirent.isDirectory();
|
||||
const isSymbolicLink = dirent.isSymbolicLink();
|
||||
|
||||
if (!isDirectory && isSymbolicLink) {
|
||||
|
||||
try {
|
||||
|
||||
try {
|
||||
const linkStats = await fsPromises.stat(entryPath);
|
||||
isDirectory = linkStats.isDirectory();
|
||||
} catch {
|
||||
@@ -4366,7 +4456,7 @@ async function main(options = {}) {
|
||||
|
||||
res.json({
|
||||
path: resolvedPath,
|
||||
entries
|
||||
entries: entries.filter(Boolean)
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to list directory:', error);
|
||||
|
||||
+100
-29
@@ -1,50 +1,108 @@
|
||||
import type { DirectoryListResult, FileSearchQuery, FileSearchResult, FilesAPI } from '@openchamber/ui/lib/api/types';
|
||||
import type {
|
||||
DirectoryListResult,
|
||||
FileSearchQuery,
|
||||
FileSearchResult,
|
||||
FilesAPI,
|
||||
} from '@openchamber/ui/lib/api/types';
|
||||
|
||||
const normalizePath = (path: string): string => path.replace(/\\/g, '/');
|
||||
|
||||
type WebDirectoryEntry = {
|
||||
name?: string;
|
||||
path?: string;
|
||||
isDirectory?: boolean;
|
||||
isFile?: boolean;
|
||||
isSymbolicLink?: boolean;
|
||||
};
|
||||
|
||||
type WebDirectoryListResponse = {
|
||||
directory?: string;
|
||||
path?: string;
|
||||
entries?: WebDirectoryEntry[];
|
||||
};
|
||||
|
||||
type WebFileSearchResponse = {
|
||||
root?: string;
|
||||
directory?: string;
|
||||
count?: number;
|
||||
files?: Array<{
|
||||
name?: string;
|
||||
path?: string;
|
||||
relativePath?: string;
|
||||
extension?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
const toDirectoryListResult = (fallbackDirectory: string, payload: WebDirectoryListResponse): DirectoryListResult => {
|
||||
const directory = normalizePath(payload?.directory || payload?.path || fallbackDirectory);
|
||||
const entries = Array.isArray(payload?.entries) ? payload.entries : [];
|
||||
|
||||
return {
|
||||
directory,
|
||||
entries: entries
|
||||
.filter((entry): entry is Required<Pick<WebDirectoryEntry, 'name' | 'path'>> & { isDirectory?: boolean } =>
|
||||
Boolean(entry && typeof entry.name === 'string' && typeof entry.path === 'string')
|
||||
)
|
||||
.map((entry) => ({
|
||||
name: entry.name,
|
||||
path: normalizePath(entry.path),
|
||||
isDirectory: Boolean(entry.isDirectory),
|
||||
})),
|
||||
};
|
||||
};
|
||||
|
||||
export const createWebFilesAPI = (): FilesAPI => ({
|
||||
async listDirectory(path: string): Promise<DirectoryListResult> {
|
||||
const target = normalizePath(path);
|
||||
const response = await fetch('/api/fs/list', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path: target }),
|
||||
});
|
||||
const params = new URLSearchParams();
|
||||
if (target) {
|
||||
params.set('path', target);
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/fs/list${params.toString() ? `?${params.toString()}` : ''}`);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: response.statusText }));
|
||||
throw new Error(error.error || 'Failed to list directory');
|
||||
throw new Error((error as { error?: string }).error || 'Failed to list directory');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
const result = (await response.json()) as WebDirectoryListResponse;
|
||||
return toDirectoryListResult(target, result);
|
||||
},
|
||||
|
||||
async search(payload: FileSearchQuery): Promise<FileSearchResult[]> {
|
||||
const response = await fetch('/api/fs/search', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
directory: normalizePath(payload.directory),
|
||||
query: payload.query,
|
||||
maxResults: payload.maxResults,
|
||||
}),
|
||||
});
|
||||
const params = new URLSearchParams();
|
||||
|
||||
const directory = normalizePath(payload.directory);
|
||||
if (directory) {
|
||||
params.set('directory', directory);
|
||||
}
|
||||
|
||||
params.set('q', payload.query);
|
||||
|
||||
if (typeof payload.maxResults === 'number' && Number.isFinite(payload.maxResults)) {
|
||||
params.set('limit', String(payload.maxResults));
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/fs/search?${params.toString()}`);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: response.statusText }));
|
||||
throw new Error(error.error || 'Failed to search files');
|
||||
throw new Error((error as { error?: string }).error || 'Failed to search files');
|
||||
}
|
||||
|
||||
const results = (await response.json()) as unknown;
|
||||
if (!Array.isArray(results)) {
|
||||
return [];
|
||||
}
|
||||
return results
|
||||
.filter((item): item is FileSearchResult => !!item && typeof item === 'object' && typeof (item as { path?: string }).path === 'string')
|
||||
.map((item) => ({
|
||||
path: normalizePath((item as FileSearchResult).path),
|
||||
score: (item as FileSearchResult).score,
|
||||
preview: (item as FileSearchResult).preview,
|
||||
const result = (await response.json()) as WebFileSearchResponse;
|
||||
const files = Array.isArray(result?.files) ? result.files : [];
|
||||
|
||||
return files
|
||||
.filter((file): file is { path: string; relativePath?: string } =>
|
||||
Boolean(file && typeof file.path === 'string')
|
||||
)
|
||||
.map((file) => ({
|
||||
path: normalizePath(file.path),
|
||||
preview: typeof file.relativePath === 'string' && file.relativePath.length > 0
|
||||
? [normalizePath(file.relativePath)]
|
||||
: undefined,
|
||||
}));
|
||||
},
|
||||
|
||||
@@ -58,7 +116,7 @@ export const createWebFilesAPI = (): FilesAPI => ({
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: response.statusText }));
|
||||
throw new Error(error.error || 'Failed to create directory');
|
||||
throw new Error((error as { error?: string }).error || 'Failed to create directory');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
@@ -67,4 +125,17 @@ export const createWebFilesAPI = (): FilesAPI => ({
|
||||
path: typeof result?.path === 'string' ? normalizePath(result.path) : target,
|
||||
};
|
||||
},
|
||||
|
||||
async readFile(path: string): Promise<{ content: string; path: string }> {
|
||||
const target = normalizePath(path);
|
||||
const response = await fetch(`/api/fs/read?path=${encodeURIComponent(target)}`);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: response.statusText }));
|
||||
throw new Error((error as { error?: string }).error || 'Failed to read file');
|
||||
}
|
||||
|
||||
const content = await response.text();
|
||||
return { content, path: target };
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user