2026-01-15 20:19:06 +02:00
|
|
|
import React from 'react';
|
2026-01-18 17:29:22 +02:00
|
|
|
|
2026-01-15 20:19:06 +02:00
|
|
|
import {
|
|
|
|
|
RiArrowLeftSLine,
|
2026-02-08 22:24:25 -03:00
|
|
|
RiArrowRightSLine,
|
2026-01-31 17:35:46 -03:00
|
|
|
RiArrowDownSLine,
|
2026-01-15 20:19:06 +02:00
|
|
|
RiClipboardLine,
|
|
|
|
|
RiCloseLine,
|
|
|
|
|
RiCodeLine,
|
|
|
|
|
RiFileImageLine,
|
|
|
|
|
RiFileTextLine,
|
2026-01-18 17:29:22 +02:00
|
|
|
RiFileCopy2Line,
|
2026-01-25 15:52:02 +02:00
|
|
|
RiCheckLine,
|
2026-01-15 20:19:06 +02:00
|
|
|
RiFolder3Fill,
|
|
|
|
|
RiFolderOpenFill,
|
2026-01-27 09:23:33 -08:00
|
|
|
RiFullscreenExitLine,
|
|
|
|
|
RiFullscreenLine,
|
2026-01-15 20:19:06 +02:00
|
|
|
RiLoader4Line,
|
|
|
|
|
RiRefreshLine,
|
|
|
|
|
RiSearchLine,
|
2026-01-18 17:29:22 +02:00
|
|
|
RiSave3Line,
|
2026-01-15 20:19:06 +02:00
|
|
|
RiTextWrap,
|
2026-01-22 12:21:19 +02:00
|
|
|
RiMore2Fill,
|
|
|
|
|
RiFileAddLine,
|
|
|
|
|
RiFolderAddLine,
|
|
|
|
|
RiDeleteBinLine,
|
|
|
|
|
RiEditLine,
|
|
|
|
|
RiFileCopyLine,
|
2026-01-15 20:19:06 +02:00
|
|
|
} from '@remixicon/react';
|
2026-01-19 02:50:40 +02:00
|
|
|
import { toast } from '@/components/ui';
|
2026-01-15 20:19:06 +02:00
|
|
|
|
2026-01-22 12:21:19 +02:00
|
|
|
import {
|
|
|
|
|
DropdownMenu,
|
|
|
|
|
DropdownMenuContent,
|
|
|
|
|
DropdownMenuItem,
|
|
|
|
|
DropdownMenuSeparator,
|
|
|
|
|
DropdownMenuTrigger,
|
|
|
|
|
} from '@/components/ui/dropdown-menu';
|
2026-01-15 20:19:06 +02:00
|
|
|
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
|
|
|
|
import { Input } from '@/components/ui/input';
|
|
|
|
|
import { Button } from '@/components/ui/button';
|
2026-02-08 22:24:25 -03:00
|
|
|
import { CodeMirrorEditor, type BlockWidgetDef } from '@/components/ui/CodeMirrorEditor';
|
2026-01-27 15:15:00 +08:00
|
|
|
import { PreviewToggleButton } from './PreviewToggleButton';
|
|
|
|
|
import { SimpleMarkdownRenderer } from '@/components/chat/MarkdownRenderer';
|
2026-01-18 17:29:22 +02:00
|
|
|
import { languageByExtension } from '@/lib/codemirror/languageByExtension';
|
|
|
|
|
import { createFlexokiCodeMirrorTheme } from '@/lib/codemirror/flexokiTheme';
|
2026-01-20 20:20:17 +07:00
|
|
|
import { generateSyntaxTheme } from '@/lib/theme/syntaxThemeGenerator';
|
2026-01-18 17:29:22 +02:00
|
|
|
import {
|
|
|
|
|
Dialog,
|
|
|
|
|
DialogContent,
|
|
|
|
|
DialogDescription,
|
|
|
|
|
DialogFooter,
|
|
|
|
|
DialogHeader,
|
|
|
|
|
DialogTitle,
|
|
|
|
|
} from '@/components/ui/dialog';
|
2026-01-15 20:19:06 +02:00
|
|
|
import { useDebouncedValue } from '@/hooks/useDebouncedValue';
|
|
|
|
|
import { useFileSearchStore } from '@/stores/useFileSearchStore';
|
|
|
|
|
import { useDeviceInfo } from '@/lib/device';
|
2026-02-08 22:24:25 -03:00
|
|
|
import { useLongPress } from '@/hooks/useLongPress';
|
2026-01-18 17:29:22 +02:00
|
|
|
import { cn, getModifierLabel, hasModifier } from '@/lib/utils';
|
2026-01-15 20:19:06 +02:00
|
|
|
import { getLanguageFromExtension, getImageMimeType, isImageFile } from '@/lib/toolHelpers';
|
|
|
|
|
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
2026-01-18 17:29:22 +02:00
|
|
|
import { EditorView } from '@codemirror/view';
|
2026-01-15 20:19:06 +02:00
|
|
|
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
|
|
|
|
import { useSessionStore } from '@/stores/useSessionStore';
|
|
|
|
|
import { useUIStore } from '@/stores/useUIStore';
|
2026-02-01 21:43:49 +02:00
|
|
|
import { useFilesViewTabsStore } from '@/stores/useFilesViewTabsStore';
|
2026-02-08 22:24:25 -03:00
|
|
|
import { useGitStatus } from '@/stores/useGitStore';
|
|
|
|
|
import { useInlineCommentDraftStore } from '@/stores/useInlineCommentDraftStore';
|
|
|
|
|
import { InlineCommentCard, InlineCommentInput } from '@/components/comments';
|
2026-01-15 20:19:06 +02:00
|
|
|
import { opencodeClient } from '@/lib/opencode/client';
|
2026-01-20 20:20:17 +07:00
|
|
|
import { useDirectoryShowHidden } from '@/lib/directoryShowHidden';
|
|
|
|
|
import { useFilesViewShowGitignored } from '@/lib/filesViewShowGitignored';
|
2026-01-27 15:15:00 +08:00
|
|
|
import { ErrorBoundary } from '@/components/ui/ErrorBoundary';
|
2026-01-27 11:57:56 +02:00
|
|
|
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
2026-01-15 20:19:06 +02:00
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
});
|
|
|
|
|
|
2026-02-09 11:50:21 +02:00
|
|
|
const normalizePath = (value: string): string => {
|
|
|
|
|
if (!value) return '';
|
|
|
|
|
|
|
|
|
|
const raw = value.replace(/\\/g, '/');
|
|
|
|
|
const hadUncPrefix = raw.startsWith('//');
|
|
|
|
|
|
|
|
|
|
let normalized = raw.replace(/\/+/g, '/');
|
|
|
|
|
if (hadUncPrefix && !normalized.startsWith('//')) {
|
|
|
|
|
normalized = `/${normalized}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const isUnixRoot = normalized === '/';
|
|
|
|
|
const isWindowsDriveRoot = /^[A-Za-z]:\/$/.test(normalized);
|
|
|
|
|
if (!isUnixRoot && !isWindowsDriveRoot) {
|
|
|
|
|
normalized = normalized.replace(/\/+$/, '');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return normalized;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const isAbsolutePath = (value: string): boolean => {
|
|
|
|
|
return value.startsWith('/') || value.startsWith('//') || /^[A-Za-z]:\//.test(value);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const toComparablePath = (value: string): string => {
|
|
|
|
|
if (/^[A-Za-z]:\//.test(value)) {
|
|
|
|
|
return value.toLowerCase();
|
|
|
|
|
}
|
|
|
|
|
return value;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const isPathWithinRoot = (path: string, root: string): boolean => {
|
|
|
|
|
const normalizedRoot = normalizePath(root);
|
|
|
|
|
const normalizedPath = normalizePath(path);
|
|
|
|
|
if (!normalizedRoot || !normalizedPath) return false;
|
|
|
|
|
|
|
|
|
|
const comparableRoot = toComparablePath(normalizedRoot);
|
|
|
|
|
const comparablePath = toComparablePath(normalizedPath);
|
|
|
|
|
return comparablePath === comparableRoot || comparablePath.startsWith(`${comparableRoot}/`);
|
|
|
|
|
};
|
2026-01-15 20:19:06 +02:00
|
|
|
|
2026-02-08 22:24:25 -03:00
|
|
|
const getAncestorPaths = (filePath: string, root: string): string[] => {
|
|
|
|
|
const normalizedRoot = normalizePath(root);
|
|
|
|
|
const normalizedFile = normalizePath(filePath);
|
2026-02-09 11:50:21 +02:00
|
|
|
|
2026-02-08 22:24:25 -03:00
|
|
|
// Ensure file is within root
|
2026-02-09 11:50:21 +02:00
|
|
|
if (!isPathWithinRoot(normalizedFile, normalizedRoot)) return [];
|
|
|
|
|
|
2026-02-08 22:24:25 -03:00
|
|
|
const relative = normalizedFile.slice(normalizedRoot.length).replace(/^\//, '');
|
|
|
|
|
const parts = relative.split('/');
|
|
|
|
|
const ancestors: string[] = [];
|
|
|
|
|
let current = normalizedRoot;
|
2026-02-09 11:50:21 +02:00
|
|
|
|
2026-02-08 22:24:25 -03:00
|
|
|
for (let i = 0; i < parts.length - 1; i++) {
|
|
|
|
|
current = current ? `${current}/${parts[i]}` : parts[i];
|
|
|
|
|
ancestors.push(current);
|
|
|
|
|
}
|
|
|
|
|
return ancestors;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
type BreadcrumbSegment = { label: string; path: string };
|
|
|
|
|
|
|
|
|
|
const parseBreadcrumbs = (relativePath: string, root: string): BreadcrumbSegment[] => {
|
|
|
|
|
const parts = relativePath.split('/');
|
|
|
|
|
const segments: BreadcrumbSegment[] = [];
|
|
|
|
|
let currentPath = root;
|
|
|
|
|
|
|
|
|
|
for (const part of parts) {
|
|
|
|
|
if (!part) continue;
|
|
|
|
|
currentPath = currentPath ? `${currentPath}/${part}` : part;
|
|
|
|
|
segments.push({ label: part, path: currentPath });
|
|
|
|
|
}
|
|
|
|
|
return segments;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const FileBreadcrumbs: React.FC<{
|
|
|
|
|
path: string;
|
|
|
|
|
root: string;
|
|
|
|
|
onNavigate: (dirPath: string) => void;
|
|
|
|
|
}> = ({ path, root, onNavigate }) => {
|
|
|
|
|
const segments = React.useMemo(() => parseBreadcrumbs(path, root), [path, root]);
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex items-center gap-1 overflow-x-auto whitespace-nowrap min-w-0 flex-1 hide-scrollbar">
|
|
|
|
|
{segments.map((seg, i) => (
|
|
|
|
|
<React.Fragment key={seg.path}>
|
|
|
|
|
{i > 0 && <RiArrowRightSLine className="h-3 w-3 flex-shrink-0 text-muted-foreground" />}
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => i < segments.length - 1 && onNavigate(seg.path)}
|
|
|
|
|
className={cn(
|
|
|
|
|
"typography-meta transition-colors",
|
|
|
|
|
i === segments.length - 1
|
|
|
|
|
? "text-foreground font-medium cursor-default"
|
|
|
|
|
: "text-muted-foreground hover:text-foreground hover:underline cursor-pointer"
|
|
|
|
|
)}
|
|
|
|
|
disabled={i === segments.length - 1}
|
|
|
|
|
>
|
|
|
|
|
{seg.label}
|
|
|
|
|
</button>
|
|
|
|
|
</React.Fragment>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
2026-01-15 20:19:06 +02:00
|
|
|
const DEFAULT_IGNORED_DIR_NAMES = new Set(['node_modules']);
|
|
|
|
|
|
2026-02-08 22:24:25 -03:00
|
|
|
type FileStatus = 'open' | 'modified' | 'git-modified' | 'git-added' | 'git-deleted';
|
|
|
|
|
|
|
|
|
|
const FileStatusDot: React.FC<{ status: FileStatus }> = ({ status }) => {
|
|
|
|
|
const color = {
|
|
|
|
|
open: 'var(--status-info)',
|
|
|
|
|
modified: 'var(--status-warning)',
|
|
|
|
|
'git-modified': 'var(--status-warning)',
|
|
|
|
|
'git-added': 'var(--status-success)',
|
|
|
|
|
'git-deleted': 'var(--status-error)',
|
|
|
|
|
}[status];
|
|
|
|
|
|
|
|
|
|
return <span className="h-2 w-2 rounded-full" style={{ backgroundColor: color }} />;
|
|
|
|
|
};
|
|
|
|
|
|
2026-01-15 20:19:06 +02:00
|
|
|
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/');
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-09 11:50:21 +02:00
|
|
|
const isDirectoryReadError = (error: unknown): boolean => {
|
|
|
|
|
const message = error instanceof Error ? error.message : String(error ?? '');
|
|
|
|
|
const normalized = message.toLowerCase();
|
|
|
|
|
return normalized.includes('is a directory') || normalized.includes('eisdir');
|
|
|
|
|
};
|
|
|
|
|
|
2026-01-15 20:19:06 +02:00
|
|
|
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();
|
2026-01-27 15:15:00 +08:00
|
|
|
|
2026-01-15 20:19:06 +02:00
|
|
|
if (ext && CODE_EXTENSIONS.has(ext)) {
|
2026-02-01 18:29:34 +02:00
|
|
|
return <RiCodeLine className="h-4 w-4 flex-shrink-0 text-[var(--status-info)]" />;
|
2026-01-15 20:19:06 +02:00
|
|
|
}
|
|
|
|
|
if (ext && DATA_EXTENSIONS.has(ext)) {
|
2026-02-01 18:29:34 +02:00
|
|
|
return <RiCodeLine className="h-4 w-4 flex-shrink-0 text-[var(--status-warning)]" />;
|
2026-01-15 20:19:06 +02:00
|
|
|
}
|
|
|
|
|
if (ext && IMAGE_EXTENSIONS.has(ext)) {
|
2026-02-01 18:29:34 +02:00
|
|
|
return <RiFileImageLine className="h-4 w-4 flex-shrink-0 text-[var(--status-success)]" />;
|
2026-01-15 20:19:06 +02:00
|
|
|
}
|
|
|
|
|
if (ext && DOCUMENT_EXTENSIONS.has(ext)) {
|
|
|
|
|
return <RiFileTextLine className="h-4 w-4 flex-shrink-0 text-muted-foreground" />;
|
|
|
|
|
}
|
|
|
|
|
return <RiFileTextLine className="h-4 w-4 flex-shrink-0 text-muted-foreground" />;
|
|
|
|
|
};
|
|
|
|
|
|
2026-01-27 15:15:00 +08:00
|
|
|
const isMarkdownFile = (path: string): boolean => {
|
|
|
|
|
if (!path) return false;
|
|
|
|
|
const ext = path.toLowerCase().split('.').pop();
|
|
|
|
|
return ext === 'md' || ext === 'markdown';
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-08 22:24:25 -03:00
|
|
|
interface FileRowProps {
|
|
|
|
|
node: FileNode;
|
|
|
|
|
isExpanded: boolean;
|
|
|
|
|
isActive: boolean;
|
|
|
|
|
isLoading: boolean;
|
|
|
|
|
isMobile: boolean;
|
|
|
|
|
status?: FileStatus | null;
|
|
|
|
|
badge?: { modified: number; added: number } | null;
|
|
|
|
|
permissions: {
|
|
|
|
|
canRename: boolean;
|
|
|
|
|
canCreateFile: boolean;
|
|
|
|
|
canCreateFolder: boolean;
|
|
|
|
|
canDelete: boolean;
|
|
|
|
|
};
|
|
|
|
|
contextMenuPath: string | null;
|
|
|
|
|
setContextMenuPath: (path: string | null) => void;
|
|
|
|
|
onSelect: (node: FileNode) => void;
|
|
|
|
|
onToggle: (path: string) => void;
|
|
|
|
|
onOpenDialog: (type: 'createFile' | 'createFolder' | 'rename' | 'delete', data: { path: string; name?: string; type?: 'file' | 'directory' }) => void;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const FileRow: React.FC<FileRowProps> = ({
|
|
|
|
|
node,
|
|
|
|
|
isExpanded,
|
|
|
|
|
isActive,
|
|
|
|
|
isLoading,
|
|
|
|
|
isMobile,
|
|
|
|
|
status,
|
|
|
|
|
badge,
|
|
|
|
|
permissions,
|
|
|
|
|
contextMenuPath,
|
|
|
|
|
setContextMenuPath,
|
|
|
|
|
onSelect,
|
|
|
|
|
onToggle,
|
|
|
|
|
onOpenDialog,
|
|
|
|
|
}) => {
|
|
|
|
|
const isDir = node.type === 'directory';
|
|
|
|
|
const { canRename, canCreateFile, canCreateFolder, canDelete } = permissions;
|
|
|
|
|
|
|
|
|
|
const handleContextMenu = React.useCallback((event?: React.MouseEvent) => {
|
|
|
|
|
if (!canRename && !canCreateFile && !canCreateFolder && !canDelete) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
event?.preventDefault();
|
|
|
|
|
setContextMenuPath(node.path);
|
|
|
|
|
}, [canRename, canCreateFile, canCreateFolder, canDelete, node.path, setContextMenuPath]);
|
|
|
|
|
|
|
|
|
|
const handleInteraction = React.useCallback(() => {
|
|
|
|
|
if (isDir) {
|
|
|
|
|
onToggle(node.path);
|
|
|
|
|
} else {
|
|
|
|
|
onSelect(node);
|
|
|
|
|
}
|
|
|
|
|
}, [isDir, node, onSelect, onToggle]);
|
|
|
|
|
|
|
|
|
|
const longPressHandlers = useLongPress({
|
|
|
|
|
onLongPress: handleContextMenu,
|
|
|
|
|
onTap: handleInteraction,
|
|
|
|
|
enableHaptic: true,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const interactionProps = isMobile ? longPressHandlers : {
|
|
|
|
|
onClick: handleInteraction,
|
|
|
|
|
onContextMenu: handleContextMenu,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
className="group relative flex items-center"
|
|
|
|
|
onContextMenu={!isMobile ? handleContextMenu : undefined}
|
|
|
|
|
>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
{...interactionProps}
|
|
|
|
|
className={cn(
|
|
|
|
|
'flex w-full items-center gap-1.5 rounded-md px-2 py-1 text-left text-foreground transition-colors pr-8 select-none',
|
|
|
|
|
isActive ? 'bg-interactive-selection/70' : 'hover:bg-interactive-hover/40'
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
{isDir ? (
|
|
|
|
|
isLoading ? (
|
|
|
|
|
<RiLoader4Line className="h-4 w-4 flex-shrink-0 animate-spin" />
|
|
|
|
|
) : isExpanded ? (
|
|
|
|
|
<RiFolderOpenFill className="h-4 w-4 flex-shrink-0 text-primary/60" />
|
|
|
|
|
) : (
|
|
|
|
|
<RiFolder3Fill className="h-4 w-4 flex-shrink-0 text-primary/60" />
|
|
|
|
|
)
|
|
|
|
|
) : (
|
|
|
|
|
getFileIcon(node.extension)
|
|
|
|
|
)}
|
|
|
|
|
<span
|
|
|
|
|
className="min-w-0 flex-1 truncate typography-meta"
|
|
|
|
|
title={node.path}
|
|
|
|
|
>
|
|
|
|
|
{node.name}
|
|
|
|
|
</span>
|
|
|
|
|
{!isDir && status && <FileStatusDot status={status} />}
|
|
|
|
|
{isDir && badge && (
|
|
|
|
|
<span className="text-xs flex items-center gap-1 ml-auto mr-1">
|
|
|
|
|
{badge.modified > 0 && <span className="text-[var(--status-warning)]">M{badge.modified}</span>}
|
|
|
|
|
{badge.added > 0 && <span className="text-[var(--status-success)]">+{badge.added}</span>}
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
</button>
|
|
|
|
|
{(canRename || canCreateFile || canCreateFolder || canDelete) && (
|
|
|
|
|
<div className={cn(
|
|
|
|
|
"absolute right-1 top-1/2 -translate-y-1/2 opacity-0 focus-within:opacity-100",
|
|
|
|
|
!isMobile && "group-hover:opacity-100",
|
|
|
|
|
(isMobile && contextMenuPath === node.path) && "opacity-100"
|
|
|
|
|
)}>
|
|
|
|
|
<DropdownMenu
|
|
|
|
|
open={contextMenuPath === node.path}
|
|
|
|
|
onOpenChange={(open) => setContextMenuPath(open ? node.path : null)}
|
|
|
|
|
>
|
|
|
|
|
{!isMobile && (
|
|
|
|
|
<DropdownMenuTrigger asChild>
|
|
|
|
|
<Button variant="ghost" size="icon" className="h-6 w-6">
|
|
|
|
|
<RiMore2Fill className="h-4 w-4" />
|
|
|
|
|
</Button>
|
|
|
|
|
</DropdownMenuTrigger>
|
|
|
|
|
)}
|
|
|
|
|
{isMobile && (
|
|
|
|
|
<DropdownMenuTrigger asChild>
|
|
|
|
|
<span className="hidden" />
|
|
|
|
|
</DropdownMenuTrigger>
|
|
|
|
|
)}
|
|
|
|
|
<DropdownMenuContent align="end" onCloseAutoFocus={() => setContextMenuPath(null)}>
|
|
|
|
|
{canRename && (
|
|
|
|
|
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); onOpenDialog('rename', node); }}>
|
|
|
|
|
<RiEditLine className="mr-2 h-4 w-4" /> Rename
|
|
|
|
|
</DropdownMenuItem>
|
|
|
|
|
)}
|
|
|
|
|
<DropdownMenuItem onClick={(e) => {
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
void navigator.clipboard.writeText(node.path);
|
|
|
|
|
toast.success('Path copied');
|
|
|
|
|
}}>
|
|
|
|
|
<RiFileCopyLine className="mr-2 h-4 w-4" /> Copy Path
|
|
|
|
|
</DropdownMenuItem>
|
|
|
|
|
{isDir && (canCreateFile || canCreateFolder) && (
|
|
|
|
|
<>
|
|
|
|
|
<DropdownMenuSeparator />
|
|
|
|
|
{canCreateFile && (
|
|
|
|
|
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); onOpenDialog('createFile', node); }}>
|
|
|
|
|
<RiFileAddLine className="mr-2 h-4 w-4" /> New File
|
|
|
|
|
</DropdownMenuItem>
|
|
|
|
|
)}
|
|
|
|
|
{canCreateFolder && (
|
|
|
|
|
<DropdownMenuItem onClick={(e) => { e.stopPropagation(); onOpenDialog('createFolder', node); }}>
|
|
|
|
|
<RiFolderAddLine className="mr-2 h-4 w-4" /> New Folder
|
|
|
|
|
</DropdownMenuItem>
|
|
|
|
|
)}
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
{canDelete && (
|
|
|
|
|
<>
|
|
|
|
|
<DropdownMenuSeparator />
|
|
|
|
|
<DropdownMenuItem
|
|
|
|
|
onClick={(e) => { e.stopPropagation(); onOpenDialog('delete', node); }}
|
|
|
|
|
className="text-destructive focus:text-destructive"
|
|
|
|
|
>
|
|
|
|
|
<RiDeleteBinLine className="mr-2 h-4 w-4" /> Delete
|
|
|
|
|
</DropdownMenuItem>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</DropdownMenuContent>
|
|
|
|
|
</DropdownMenu>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
2026-01-15 20:19:06 +02:00
|
|
|
export const FilesView: React.FC = () => {
|
|
|
|
|
const { files, runtime } = useRuntimeAPIs();
|
|
|
|
|
const { currentTheme } = useThemeSystem();
|
2026-01-20 20:20:17 +07:00
|
|
|
React.useMemo(() => generateSyntaxTheme(currentTheme), [currentTheme]);
|
2026-01-18 17:29:22 +02:00
|
|
|
const { isMobile, screenWidth } = useDeviceInfo();
|
2026-01-20 20:20:17 +07:00
|
|
|
const showHidden = useDirectoryShowHidden();
|
|
|
|
|
const showGitignored = useFilesViewShowGitignored();
|
2026-01-15 20:19:06 +02:00
|
|
|
|
2026-01-27 11:57:56 +02:00
|
|
|
const currentDirectory = useEffectiveDirectory() ?? '';
|
2026-02-01 21:43:49 +02:00
|
|
|
const root = normalizePath(currentDirectory.trim());
|
2026-01-15 20:19:06 +02:00
|
|
|
const searchFiles = useFileSearchStore((state) => state.searchFiles);
|
2026-02-08 22:24:25 -03:00
|
|
|
const gitStatus = useGitStatus(currentDirectory);
|
2026-01-15 20:19:06 +02:00
|
|
|
|
|
|
|
|
const [searchQuery, setSearchQuery] = React.useState('');
|
|
|
|
|
const debouncedSearchQuery = useDebouncedValue(searchQuery, 200);
|
|
|
|
|
const searchInputRef = React.useRef<HTMLInputElement>(null);
|
|
|
|
|
|
|
|
|
|
const [showMobilePageContent, setShowMobilePageContent] = React.useState(false);
|
|
|
|
|
const [wrapLines, setWrapLines] = React.useState(isMobile);
|
2026-01-27 09:23:33 -08:00
|
|
|
const [isFullscreen, setIsFullscreen] = React.useState(false);
|
2026-02-08 22:24:25 -03:00
|
|
|
const [isSearchOpen, setIsSearchOpen] = React.useState(false);
|
2026-01-15 20:19:06 +02:00
|
|
|
|
2026-02-01 21:43:49 +02:00
|
|
|
const EMPTY_PATHS: string[] = React.useMemo(() => [], []);
|
|
|
|
|
const openPaths = useFilesViewTabsStore((state) => (root ? (state.byRoot[root]?.openPaths ?? EMPTY_PATHS) : EMPTY_PATHS));
|
|
|
|
|
const selectedPath = useFilesViewTabsStore((state) => (root ? (state.byRoot[root]?.selectedPath ?? null) : null));
|
2026-02-08 22:24:25 -03:00
|
|
|
const expandedPaths = useFilesViewTabsStore((state) => (root ? (state.byRoot[root]?.expandedPaths ?? EMPTY_PATHS) : EMPTY_PATHS));
|
2026-02-01 21:43:49 +02:00
|
|
|
const addOpenPath = useFilesViewTabsStore((state) => state.addOpenPath);
|
|
|
|
|
const removeOpenPath = useFilesViewTabsStore((state) => state.removeOpenPath);
|
|
|
|
|
const removeOpenPathsByPrefix = useFilesViewTabsStore((state) => state.removeOpenPathsByPrefix);
|
|
|
|
|
const setSelectedPath = useFilesViewTabsStore((state) => state.setSelectedPath);
|
2026-02-08 22:24:25 -03:00
|
|
|
const toggleExpandedPath = useFilesViewTabsStore((state) => state.toggleExpandedPath);
|
|
|
|
|
const expandPaths = useFilesViewTabsStore((state) => state.expandPaths);
|
2026-02-01 21:43:49 +02:00
|
|
|
|
|
|
|
|
const toFileNode = React.useCallback((path: string): FileNode => {
|
|
|
|
|
const normalized = normalizePath(path);
|
|
|
|
|
const parts = normalized.split('/');
|
|
|
|
|
const name = parts[parts.length - 1] || normalized;
|
|
|
|
|
const extension = name.includes('.') ? name.split('.').pop()?.toLowerCase() : undefined;
|
|
|
|
|
return {
|
|
|
|
|
name,
|
|
|
|
|
path: normalized,
|
|
|
|
|
type: 'file',
|
|
|
|
|
extension,
|
|
|
|
|
};
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const openFiles = React.useMemo(() => openPaths.map(toFileNode), [openPaths, toFileNode]);
|
|
|
|
|
const effectiveSelectedPath = React.useMemo(() => selectedPath ?? openPaths[0] ?? null, [openPaths, selectedPath]);
|
|
|
|
|
const selectedFile = React.useMemo(() => (effectiveSelectedPath ? toFileNode(effectiveSelectedPath) : null), [effectiveSelectedPath, toFileNode]);
|
|
|
|
|
|
2026-02-08 22:24:25 -03:00
|
|
|
const [childrenByDir, setChildrenByDir] = React.useState<Record<string, FileNode[]>>({});
|
|
|
|
|
const loadedDirsRef = React.useRef<Set<string>>(new Set());
|
|
|
|
|
const inFlightDirsRef = React.useRef<Set<string>>(new Set());
|
|
|
|
|
|
|
|
|
|
const [searchResults, setSearchResults] = React.useState<FileNode[]>([]);
|
|
|
|
|
const [searching, setSearching] = React.useState(false);
|
|
|
|
|
|
2026-01-15 20:19:06 +02:00
|
|
|
const [fileContent, setFileContent] = React.useState<string>('');
|
|
|
|
|
const [fileLoading, setFileLoading] = React.useState(false);
|
|
|
|
|
const [fileError, setFileError] = React.useState<string | null>(null);
|
|
|
|
|
const [desktopImageSrc, setDesktopImageSrc] = React.useState<string>('');
|
|
|
|
|
|
2026-02-01 21:43:49 +02:00
|
|
|
const [loadedFilePath, setLoadedFilePath] = React.useState<string | null>(null);
|
|
|
|
|
|
2026-01-18 17:29:22 +02:00
|
|
|
const [draftContent, setDraftContent] = React.useState('');
|
|
|
|
|
const [isSaving, setIsSaving] = React.useState(false);
|
|
|
|
|
|
|
|
|
|
const [confirmDiscardOpen, setConfirmDiscardOpen] = React.useState(false);
|
|
|
|
|
const pendingSelectFileRef = React.useRef<FileNode | null>(null);
|
|
|
|
|
const pendingTabRef = React.useRef<import('@/stores/useUIStore').MainTab | null>(null);
|
2026-01-31 17:35:46 -03:00
|
|
|
const pendingClosePathRef = React.useRef<string | null>(null);
|
2026-01-18 17:29:22 +02:00
|
|
|
const skipDirtyOnceRef = React.useRef(false);
|
2026-01-25 15:52:02 +02:00
|
|
|
const copiedContentTimeoutRef = React.useRef<number | null>(null);
|
|
|
|
|
const copiedPathTimeoutRef = React.useRef<number | null>(null);
|
2026-02-11 23:49:10 -08:00
|
|
|
const editorViewRef = React.useRef<EditorView | null>(null);
|
2026-01-18 17:29:22 +02:00
|
|
|
|
2026-01-22 12:21:19 +02:00
|
|
|
const [activeDialog, setActiveDialog] = React.useState<'createFile' | 'createFolder' | 'rename' | 'delete' | null>(null);
|
|
|
|
|
const [dialogData, setDialogData] = React.useState<{ path: string; name?: string; type?: 'file' | 'directory' } | null>(null);
|
|
|
|
|
const [dialogInputValue, setDialogInputValue] = React.useState('');
|
|
|
|
|
const [isDialogSubmitting, setIsDialogSubmitting] = React.useState(false);
|
|
|
|
|
const [contextMenuPath, setContextMenuPath] = React.useState<string | null>(null);
|
2026-01-25 15:52:02 +02:00
|
|
|
const [copiedContent, setCopiedContent] = React.useState(false);
|
|
|
|
|
const [copiedPath, setCopiedPath] = React.useState(false);
|
2026-01-22 12:21:19 +02:00
|
|
|
|
2026-01-27 15:15:00 +08:00
|
|
|
// Markdown view mode (global, not per-file)
|
|
|
|
|
const [mdViewMode, setMdViewMode] = React.useState<'preview' | 'edit'>('edit');
|
|
|
|
|
|
2026-01-22 12:21:19 +02:00
|
|
|
const canCreateFile = Boolean(files.writeFile);
|
|
|
|
|
const canCreateFolder = Boolean(files.createDirectory);
|
|
|
|
|
const canRename = Boolean(files.rename);
|
|
|
|
|
const canDelete = Boolean(files.delete);
|
|
|
|
|
|
|
|
|
|
const handleOpenDialog = React.useCallback((type: 'createFile' | 'createFolder' | 'rename' | 'delete', data: { path: string; name?: string; type?: 'file' | 'directory' }) => {
|
|
|
|
|
setActiveDialog(type);
|
|
|
|
|
setDialogData(data);
|
|
|
|
|
setDialogInputValue(type === 'rename' ? data.name || '' : '');
|
|
|
|
|
setIsDialogSubmitting(false);
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-01-15 20:19:06 +02:00
|
|
|
// Line selection state for commenting
|
|
|
|
|
const [lineSelection, setLineSelection] = React.useState<SelectedLineRange | null>(null);
|
|
|
|
|
const isSelectingRef = React.useRef(false);
|
|
|
|
|
const selectionStartRef = React.useRef<number | null>(null);
|
2026-02-09 11:52:19 -03:00
|
|
|
const [isDragging, setIsDragging] = React.useState(false);
|
2026-01-15 20:19:06 +02:00
|
|
|
|
|
|
|
|
// Session/config for sending comments
|
|
|
|
|
const currentSessionId = useSessionStore((state) => state.currentSessionId);
|
2026-01-18 17:29:22 +02:00
|
|
|
const setMainTabGuard = useUIStore((state) => state.setMainTabGuard);
|
2026-01-15 20:19:06 +02:00
|
|
|
|
2026-02-08 22:24:25 -03:00
|
|
|
const addDraft = useInlineCommentDraftStore((s) => s.addDraft);
|
|
|
|
|
const updateDraft = useInlineCommentDraftStore((s) => s.updateDraft);
|
|
|
|
|
const removeDraft = useInlineCommentDraftStore((s) => s.removeDraft);
|
|
|
|
|
const allDrafts = useInlineCommentDraftStore((s) => s.drafts);
|
|
|
|
|
const [editingDraftId, setEditingDraftId] = React.useState<string | null>(null);
|
2026-01-15 20:19:06 +02:00
|
|
|
|
|
|
|
|
// Global mouseup to end drag selection
|
|
|
|
|
React.useEffect(() => {
|
|
|
|
|
const handleGlobalMouseUp = () => {
|
|
|
|
|
isSelectingRef.current = false;
|
2026-01-18 17:29:22 +02:00
|
|
|
selectionStartRef.current = null;
|
2026-02-09 11:52:19 -03:00
|
|
|
setIsDragging(false);
|
2026-01-15 20:19:06 +02:00
|
|
|
};
|
|
|
|
|
document.addEventListener('mouseup', handleGlobalMouseUp);
|
|
|
|
|
return () => document.removeEventListener('mouseup', handleGlobalMouseUp);
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
// Clear selection when file changes
|
|
|
|
|
React.useEffect(() => {
|
|
|
|
|
setLineSelection(null);
|
2026-01-18 17:29:22 +02:00
|
|
|
setMainTabGuard(null);
|
|
|
|
|
setDraftContent('');
|
|
|
|
|
setIsSaving(false);
|
|
|
|
|
}, [selectedFile?.path, setMainTabGuard]);
|
2026-01-15 20:19:06 +02:00
|
|
|
|
2026-01-25 15:52:02 +02:00
|
|
|
React.useEffect(() => {
|
|
|
|
|
return () => {
|
|
|
|
|
if (copiedContentTimeoutRef.current !== null) {
|
|
|
|
|
window.clearTimeout(copiedContentTimeoutRef.current);
|
|
|
|
|
}
|
|
|
|
|
if (copiedPathTimeoutRef.current !== null) {
|
|
|
|
|
window.clearTimeout(copiedPathTimeoutRef.current);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-01-15 20:19:06 +02:00
|
|
|
// Click outside to dismiss selection
|
|
|
|
|
React.useEffect(() => {
|
2026-02-08 22:24:25 -03:00
|
|
|
if (!lineSelection && !editingDraftId) return;
|
2026-01-15 20:19:06 +02:00
|
|
|
|
|
|
|
|
const handleClickOutside = (e: MouseEvent) => {
|
|
|
|
|
const target = e.target as HTMLElement;
|
2026-02-08 22:24:25 -03:00
|
|
|
|
2026-01-15 20:19:06 +02:00
|
|
|
// Check if click is inside comment UI
|
2026-02-08 22:24:25 -03:00
|
|
|
if (target.closest('[data-comment-input="true"]') || target.closest('[data-comment-card="true"]')) return;
|
|
|
|
|
|
2026-01-18 17:29:22 +02:00
|
|
|
// Check if click is on CM gutter (only gutter should not dismiss)
|
|
|
|
|
if (target.closest('.cm-gutterElement')) return;
|
2026-01-15 20:19:06 +02:00
|
|
|
|
|
|
|
|
// 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);
|
2026-02-08 22:24:25 -03:00
|
|
|
setEditingDraftId(null);
|
2026-01-15 20:19:06 +02:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const timeoutId = setTimeout(() => {
|
|
|
|
|
document.addEventListener('click', handleClickOutside);
|
|
|
|
|
}, 100);
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
clearTimeout(timeoutId);
|
|
|
|
|
document.removeEventListener('click', handleClickOutside);
|
|
|
|
|
};
|
2026-02-08 22:24:25 -03:00
|
|
|
}, [lineSelection, editingDraftId]);
|
2026-01-15 20:19:06 +02:00
|
|
|
|
|
|
|
|
// 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');
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-02-08 22:24:25 -03:00
|
|
|
const handleSaveComment = React.useCallback((text: string, range?: { start: number; end: number }) => {
|
|
|
|
|
if (!selectedFile) return;
|
2026-01-15 20:19:06 +02:00
|
|
|
|
2026-02-08 22:24:25 -03:00
|
|
|
const sessionKey = currentSessionId ?? 'draft';
|
|
|
|
|
const finalRange = range || lineSelection;
|
|
|
|
|
if (!finalRange) return;
|
2026-01-15 20:19:06 +02:00
|
|
|
|
2026-02-08 22:24:25 -03:00
|
|
|
const code = extractSelectedCode(fileContent, { start: finalRange.start, end: finalRange.end });
|
2026-01-15 20:19:06 +02:00
|
|
|
|
2026-02-08 22:24:25 -03:00
|
|
|
if (editingDraftId) {
|
|
|
|
|
updateDraft(sessionKey, editingDraftId, {
|
|
|
|
|
text: text.trim(),
|
|
|
|
|
code,
|
|
|
|
|
startLine: finalRange.start,
|
|
|
|
|
endLine: finalRange.end,
|
|
|
|
|
});
|
2026-02-01 22:43:46 +02:00
|
|
|
} else {
|
2026-02-08 22:24:25 -03:00
|
|
|
addDraft({
|
|
|
|
|
sessionKey,
|
|
|
|
|
source: 'file',
|
2026-02-09 11:52:41 -03:00
|
|
|
fileLabel: selectedFile.path,
|
2026-02-08 22:24:25 -03:00
|
|
|
startLine: finalRange.start,
|
|
|
|
|
endLine: finalRange.end,
|
|
|
|
|
code,
|
|
|
|
|
language: getLanguageFromExtension(selectedFile.path) || 'text',
|
|
|
|
|
text: text.trim(),
|
2026-02-01 22:43:46 +02:00
|
|
|
});
|
|
|
|
|
}
|
2026-02-08 22:24:25 -03:00
|
|
|
|
|
|
|
|
setLineSelection(null);
|
|
|
|
|
setEditingDraftId(null);
|
|
|
|
|
}, [selectedFile, currentSessionId, lineSelection, fileContent, extractSelectedCode, editingDraftId, updateDraft, addDraft]);
|
2026-01-15 20:19:06 +02:00
|
|
|
|
|
|
|
|
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)
|
2026-01-20 20:20:17 +07:00
|
|
|
.filter((entry) => showHidden || !entry.name.startsWith('.'))
|
|
|
|
|
.filter((entry) => showGitignored || !shouldIgnoreEntryName(entry.name))
|
2026-01-15 20:19:06 +02:00
|
|
|
.map<FileNode>((entry) => {
|
|
|
|
|
const name = entry.name;
|
2026-02-09 11:50:21 +02:00
|
|
|
const normalizedEntryPath = normalizePath(entry.path || '');
|
|
|
|
|
const path = normalizedEntryPath
|
|
|
|
|
? (isAbsolutePath(normalizedEntryPath)
|
|
|
|
|
? normalizedEntryPath
|
|
|
|
|
: normalizePath(`${dirPath}/${normalizedEntryPath}`))
|
|
|
|
|
: normalizePath(`${dirPath}/${name}`);
|
2026-01-15 20:19:06 +02:00
|
|
|
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);
|
2026-01-20 20:20:17 +07:00
|
|
|
}, [showGitignored, showHidden]);
|
2026-01-15 20:19:06 +02:00
|
|
|
|
|
|
|
|
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 {
|
2026-01-20 20:20:17 +07:00
|
|
|
const respectGitignore = !showGitignored;
|
2026-01-15 20:19:06 +02:00
|
|
|
let entries: Array<{ name: string; path: string; isDirectory: boolean }>;
|
|
|
|
|
if (runtime.isDesktop) {
|
2026-01-20 20:20:17 +07:00
|
|
|
const result = await files.listDirectory(normalizedDir, { respectGitignore });
|
2026-01-15 20:19:06 +02:00
|
|
|
entries = result.entries.map((entry) => ({
|
|
|
|
|
name: entry.name,
|
|
|
|
|
path: entry.path,
|
|
|
|
|
isDirectory: entry.isDirectory,
|
|
|
|
|
}));
|
|
|
|
|
} else {
|
2026-01-20 20:20:17 +07:00
|
|
|
const result = await opencodeClient.listLocalDirectory(normalizedDir, { respectGitignore });
|
2026-01-15 20:19:06 +02:00
|
|
|
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);
|
|
|
|
|
}
|
2026-01-20 20:20:17 +07:00
|
|
|
}, [files, mapDirectoryEntries, runtime.isDesktop, showGitignored]);
|
2026-01-15 20:19:06 +02:00
|
|
|
|
|
|
|
|
const refreshRoot = React.useCallback(async () => {
|
2026-02-01 21:43:49 +02:00
|
|
|
if (!root) {
|
2026-01-15 20:19:06 +02:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
loadedDirsRef.current = new Set();
|
|
|
|
|
inFlightDirsRef.current = new Set();
|
2026-02-01 21:43:49 +02:00
|
|
|
setChildrenByDir((prev) => (Object.keys(prev).length === 0 ? prev : {}));
|
2026-01-15 20:19:06 +02:00
|
|
|
|
2026-02-01 21:43:49 +02:00
|
|
|
await loadDirectory(root);
|
|
|
|
|
}, [loadDirectory, root]);
|
2026-01-15 20:19:06 +02:00
|
|
|
|
2026-02-01 21:43:49 +02:00
|
|
|
const lastFilesViewDirRef = React.useRef<string>('');
|
|
|
|
|
const lastFilesViewTreeKeyRef = React.useRef<string>('');
|
2026-01-15 20:19:06 +02:00
|
|
|
|
|
|
|
|
React.useEffect(() => {
|
2026-02-01 21:43:49 +02:00
|
|
|
if (!root) {
|
2026-01-15 20:19:06 +02:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-01 21:43:49 +02:00
|
|
|
const treeKey = `${root}|h${showHidden ? '1' : '0'}|g${showGitignored ? '1' : '0'}`;
|
|
|
|
|
const dirChanged = lastFilesViewDirRef.current !== root;
|
|
|
|
|
const treeKeyChanged = lastFilesViewTreeKeyRef.current !== treeKey;
|
2026-01-15 20:19:06 +02:00
|
|
|
|
2026-02-01 21:43:49 +02:00
|
|
|
if (!dirChanged && !treeKeyChanged) {
|
2026-01-20 20:20:17 +07:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-01 21:43:49 +02:00
|
|
|
if (dirChanged) {
|
|
|
|
|
lastFilesViewDirRef.current = root;
|
|
|
|
|
setFileContent('');
|
|
|
|
|
setFileError(null);
|
|
|
|
|
setDesktopImageSrc('');
|
|
|
|
|
setLoadedFilePath(null);
|
|
|
|
|
setShowMobilePageContent(false);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (treeKeyChanged) {
|
|
|
|
|
lastFilesViewTreeKeyRef.current = treeKey;
|
|
|
|
|
loadedDirsRef.current = new Set();
|
|
|
|
|
inFlightDirsRef.current = new Set();
|
|
|
|
|
setChildrenByDir((prev) => (Object.keys(prev).length === 0 ? prev : {}));
|
|
|
|
|
void loadDirectory(root);
|
|
|
|
|
}
|
|
|
|
|
}, [loadDirectory, root, showGitignored, showHidden]);
|
2026-01-20 20:20:17 +07:00
|
|
|
|
2026-01-27 15:15:00 +08:00
|
|
|
const MD_VIEWER_MODE_KEY = 'openchamber:files:md-viewer-mode';
|
|
|
|
|
|
|
|
|
|
// Load markdown view mode preference from localStorage on mount
|
|
|
|
|
React.useEffect(() => {
|
|
|
|
|
try {
|
|
|
|
|
const stored = localStorage.getItem(MD_VIEWER_MODE_KEY);
|
|
|
|
|
if (stored) {
|
|
|
|
|
const parsed = JSON.parse(stored);
|
|
|
|
|
if (parsed === 'preview' || parsed === 'edit') {
|
|
|
|
|
setMdViewMode(parsed);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
// Ignore localStorage errors
|
|
|
|
|
}
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
// Save markdown view mode preference to localStorage
|
|
|
|
|
const saveMdViewMode = React.useCallback((mode: 'preview' | 'edit') => {
|
|
|
|
|
setMdViewMode(mode);
|
|
|
|
|
try {
|
|
|
|
|
localStorage.setItem(MD_VIEWER_MODE_KEY, JSON.stringify(mode));
|
|
|
|
|
} catch {
|
|
|
|
|
// Ignore localStorage errors
|
|
|
|
|
}
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
// Get the view mode for a markdown file (from state, default to 'edit')
|
|
|
|
|
const getMdViewMode = React.useCallback((): 'preview' | 'edit' => {
|
|
|
|
|
return mdViewMode;
|
|
|
|
|
}, [mdViewMode]);
|
|
|
|
|
|
2026-01-22 12:21:19 +02:00
|
|
|
const handleDialogSubmit = React.useCallback(async (e?: React.FormEvent) => {
|
|
|
|
|
e?.preventDefault();
|
|
|
|
|
if (!dialogData || !activeDialog) return;
|
|
|
|
|
|
|
|
|
|
setIsDialogSubmitting(true);
|
|
|
|
|
try {
|
|
|
|
|
if (activeDialog === 'createFile') {
|
|
|
|
|
if (!dialogInputValue.trim()) throw new Error('Filename is required');
|
|
|
|
|
const parentPath = dialogData.path;
|
|
|
|
|
// Handle root path or empty path
|
|
|
|
|
const prefix = parentPath ? `${parentPath}/` : '';
|
|
|
|
|
const newPath = normalizePath(`${prefix}${dialogInputValue.trim()}`);
|
|
|
|
|
|
|
|
|
|
if (!files.writeFile) throw new Error('Write not supported');
|
|
|
|
|
const result = await files.writeFile(newPath, '');
|
|
|
|
|
if (result.success) {
|
|
|
|
|
toast.success('File created');
|
|
|
|
|
await refreshRoot();
|
|
|
|
|
}
|
|
|
|
|
} else if (activeDialog === 'createFolder') {
|
|
|
|
|
if (!dialogInputValue.trim()) throw new Error('Folder name is required');
|
|
|
|
|
const parentPath = dialogData.path;
|
|
|
|
|
const prefix = parentPath ? `${parentPath}/` : '';
|
|
|
|
|
const newPath = normalizePath(`${prefix}${dialogInputValue.trim()}`);
|
|
|
|
|
|
|
|
|
|
const result = await files.createDirectory(newPath);
|
|
|
|
|
if (result.success) {
|
|
|
|
|
toast.success('Folder created');
|
|
|
|
|
await refreshRoot();
|
|
|
|
|
}
|
|
|
|
|
} else if (activeDialog === 'rename') {
|
|
|
|
|
if (!dialogInputValue.trim()) throw new Error('Name is required');
|
|
|
|
|
const oldPath = dialogData.path;
|
|
|
|
|
const parentDir = oldPath.split('/').slice(0, -1).join('/');
|
|
|
|
|
const prefix = parentDir ? `${parentDir}/` : '';
|
|
|
|
|
const newPath = normalizePath(`${prefix}${dialogInputValue.trim()}`);
|
|
|
|
|
|
|
|
|
|
if (files.rename) {
|
2026-02-01 21:43:49 +02:00
|
|
|
const result = await files.rename(oldPath, newPath);
|
|
|
|
|
if (result.success) {
|
|
|
|
|
toast.success('Renamed successfully');
|
|
|
|
|
await refreshRoot();
|
|
|
|
|
if (root) {
|
|
|
|
|
removeOpenPathsByPrefix(root, oldPath);
|
|
|
|
|
}
|
|
|
|
|
if (selectedFile?.path === oldPath || selectedFile?.path.startsWith(`${oldPath}/`)) {
|
|
|
|
|
if (root) {
|
|
|
|
|
setSelectedPath(root, null);
|
|
|
|
|
}
|
|
|
|
|
setFileContent('');
|
|
|
|
|
setFileError(null);
|
|
|
|
|
setDesktopImageSrc('');
|
|
|
|
|
setLoadedFilePath(null);
|
|
|
|
|
if (isMobile) {
|
|
|
|
|
setShowMobilePageContent(false);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-01-22 12:21:19 +02:00
|
|
|
} else {
|
|
|
|
|
toast.error("Rename not supported");
|
|
|
|
|
}
|
|
|
|
|
} else if (activeDialog === 'delete') {
|
|
|
|
|
if (files.delete) {
|
2026-02-01 21:43:49 +02:00
|
|
|
const result = await files.delete(dialogData.path);
|
|
|
|
|
if (result.success) {
|
|
|
|
|
toast.success('Deleted successfully');
|
|
|
|
|
await refreshRoot();
|
|
|
|
|
if (root) {
|
|
|
|
|
removeOpenPathsByPrefix(root, dialogData.path);
|
|
|
|
|
}
|
|
|
|
|
if (selectedFile?.path === dialogData.path || selectedFile?.path.startsWith(dialogData.path + '/')) {
|
|
|
|
|
if (root) {
|
|
|
|
|
setSelectedPath(root, null);
|
|
|
|
|
}
|
|
|
|
|
setFileContent('');
|
|
|
|
|
setFileError(null);
|
|
|
|
|
setDesktopImageSrc('');
|
|
|
|
|
setLoadedFilePath(null);
|
|
|
|
|
if (isMobile) {
|
|
|
|
|
setShowMobilePageContent(false);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-01-22 12:21:19 +02:00
|
|
|
} else {
|
|
|
|
|
toast.error("Delete not supported");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
setActiveDialog(null);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
toast.error(error instanceof Error ? error.message : 'Operation failed');
|
|
|
|
|
} finally {
|
|
|
|
|
setIsDialogSubmitting(false);
|
|
|
|
|
}
|
2026-02-01 21:43:49 +02:00
|
|
|
}, [activeDialog, dialogData, dialogInputValue, files, refreshRoot, isMobile, removeOpenPathsByPrefix, root, selectedFile?.path, setSelectedPath]);
|
2026-01-22 12:21:19 +02:00
|
|
|
|
2026-01-15 20:19:06 +02:00
|
|
|
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);
|
|
|
|
|
|
2026-01-20 20:20:17 +07:00
|
|
|
searchFiles(currentDirectory, trimmedQuery, 150, {
|
|
|
|
|
includeHidden: showHidden,
|
|
|
|
|
respectGitignore: !showGitignored,
|
|
|
|
|
})
|
2026-01-15 20:19:06 +02:00
|
|
|
.then((hits) => {
|
|
|
|
|
if (cancelled) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-20 20:20:17 +07:00
|
|
|
const filtered = hits.filter((hit) => showGitignored || !shouldIgnorePath(hit.path));
|
2026-01-15 20:19:06 +02:00
|
|
|
|
|
|
|
|
// 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;
|
|
|
|
|
};
|
2026-01-20 20:20:17 +07:00
|
|
|
}, [currentDirectory, debouncedSearchQuery, fuzzyScore, searchFiles, showHidden, showGitignored]);
|
2026-01-15 20:19:06 +02:00
|
|
|
|
|
|
|
|
const readFile = React.useCallback(async (path: string): Promise<string> => {
|
|
|
|
|
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]);
|
|
|
|
|
|
2026-01-18 17:29:22 +02:00
|
|
|
const displayedContent = React.useMemo(() => {
|
|
|
|
|
return fileContent.length > MAX_VIEW_CHARS
|
|
|
|
|
? `${fileContent.slice(0, MAX_VIEW_CHARS)}\n\n… truncated …`
|
|
|
|
|
: fileContent;
|
|
|
|
|
}, [fileContent]);
|
|
|
|
|
|
|
|
|
|
const isDirty = React.useMemo(() => draftContent !== displayedContent, [draftContent, displayedContent]);
|
|
|
|
|
|
|
|
|
|
const saveDraft = React.useCallback(async () => {
|
|
|
|
|
if (!selectedFile || !files.writeFile) {
|
|
|
|
|
toast.error('Saving not supported');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!isDirty) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setIsSaving(true);
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const result = await files.writeFile(selectedFile.path, draftContent);
|
|
|
|
|
if (!result?.success) {
|
|
|
|
|
throw new Error('Failed to write file');
|
|
|
|
|
}
|
|
|
|
|
setFileContent(draftContent);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
toast.error(error instanceof Error ? error.message : 'Save failed');
|
|
|
|
|
} finally {
|
|
|
|
|
setIsSaving(false);
|
|
|
|
|
}
|
|
|
|
|
}, [draftContent, files, isDirty, selectedFile]);
|
|
|
|
|
|
|
|
|
|
React.useEffect(() => {
|
|
|
|
|
if (!isDirty) {
|
|
|
|
|
setMainTabGuard(null);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const guard = (_nextTab: import('@/stores/useUIStore').MainTab) => {
|
|
|
|
|
if (skipDirtyOnceRef.current) {
|
|
|
|
|
skipDirtyOnceRef.current = false;
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
setConfirmDiscardOpen(true);
|
|
|
|
|
pendingTabRef.current = _nextTab;
|
|
|
|
|
return false;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
setMainTabGuard(guard);
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
const currentGuard = useUIStore.getState().mainTabGuard;
|
|
|
|
|
if (currentGuard === guard) {
|
|
|
|
|
setMainTabGuard(null);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
}, [isDirty, setMainTabGuard]);
|
|
|
|
|
|
|
|
|
|
React.useEffect(() => {
|
|
|
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
|
|
|
if (!hasModifier(e)) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-08 22:24:25 -03:00
|
|
|
if (e.key.toLowerCase() === 's') {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
if (!isSaving) {
|
|
|
|
|
void saveDraft();
|
|
|
|
|
}
|
|
|
|
|
} else if (e.key.toLowerCase() === 'f') {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
setIsSearchOpen(true);
|
2026-01-18 17:29:22 +02:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
window.addEventListener('keydown', handleKeyDown);
|
|
|
|
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
|
|
|
}, [isSaving, saveDraft]);
|
|
|
|
|
|
2026-02-01 21:43:49 +02:00
|
|
|
const loadSelectedFile = React.useCallback(async (node: FileNode) => {
|
2026-01-15 20:19:06 +02:00
|
|
|
setFileError(null);
|
|
|
|
|
setDesktopImageSrc('');
|
2026-02-01 21:43:49 +02:00
|
|
|
setLoadedFilePath(node.path);
|
2026-01-15 20:19:06 +02:00
|
|
|
|
|
|
|
|
const selectedIsImage = isImageFile(node.path);
|
2026-02-01 21:43:49 +02:00
|
|
|
const isSvg = node.path.toLowerCase().endsWith('.svg');
|
2026-01-15 20:19:06 +02:00
|
|
|
|
|
|
|
|
if (isMobile) {
|
|
|
|
|
setShowMobilePageContent(true);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Desktop: binary images are loaded via readFileBinary (data URL).
|
|
|
|
|
if (runtime.isDesktop && selectedIsImage && !isSvg) {
|
|
|
|
|
setFileContent('');
|
2026-02-01 21:43:49 +02:00
|
|
|
setDraftContent('');
|
2026-01-15 20:19:06 +02:00
|
|
|
setFileLoading(true);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Web: binary images should not be read as utf8.
|
|
|
|
|
if (!runtime.isDesktop && selectedIsImage && !isSvg) {
|
|
|
|
|
setFileContent('');
|
2026-02-01 21:43:49 +02:00
|
|
|
setDraftContent('');
|
2026-01-15 20:19:06 +02:00
|
|
|
setFileLoading(false);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setFileLoading(true);
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const content = await readFile(node.path);
|
|
|
|
|
setFileContent(content);
|
2026-01-18 17:29:22 +02:00
|
|
|
setDraftContent(content.length > MAX_VIEW_CHARS
|
|
|
|
|
? `${content.slice(0, MAX_VIEW_CHARS)}\n\n… truncated …`
|
|
|
|
|
: content);
|
2026-01-15 20:19:06 +02:00
|
|
|
} catch (error) {
|
2026-02-09 11:50:21 +02:00
|
|
|
if (isDirectoryReadError(error)) {
|
|
|
|
|
if (root) {
|
|
|
|
|
setSelectedPath(root, null);
|
|
|
|
|
}
|
|
|
|
|
setFileError(null);
|
|
|
|
|
setFileContent('');
|
|
|
|
|
setDraftContent('');
|
|
|
|
|
setLoadedFilePath(null);
|
|
|
|
|
if (searchQuery.trim().length > 0) {
|
|
|
|
|
setSearchQuery('');
|
|
|
|
|
}
|
|
|
|
|
if (isMobile) {
|
|
|
|
|
setShowMobilePageContent(false);
|
|
|
|
|
}
|
|
|
|
|
if (root) {
|
|
|
|
|
const ancestors = getAncestorPaths(node.path, root);
|
|
|
|
|
const pathsToExpand = [...ancestors, node.path];
|
|
|
|
|
if (pathsToExpand.length > 0) {
|
|
|
|
|
expandPaths(root, pathsToExpand);
|
|
|
|
|
}
|
|
|
|
|
for (const path of pathsToExpand) {
|
|
|
|
|
if (!loadedDirsRef.current.has(path)) {
|
|
|
|
|
void loadDirectory(path);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-01-15 20:19:06 +02:00
|
|
|
setFileContent('');
|
2026-01-18 17:29:22 +02:00
|
|
|
setDraftContent('');
|
2026-01-15 20:19:06 +02:00
|
|
|
setFileError(error instanceof Error ? error.message : 'Failed to read file');
|
|
|
|
|
} finally {
|
|
|
|
|
setFileLoading(false);
|
|
|
|
|
}
|
2026-02-09 11:50:21 +02:00
|
|
|
}, [expandPaths, isMobile, loadDirectory, readFile, root, runtime.isDesktop, searchQuery, setSelectedPath]);
|
|
|
|
|
|
|
|
|
|
const ensurePathVisible = React.useCallback(async (targetPath: string, includeTarget: boolean) => {
|
|
|
|
|
if (!root) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const ancestors = getAncestorPaths(targetPath, root);
|
|
|
|
|
const pathsToExpand = includeTarget ? [...ancestors, targetPath] : ancestors;
|
|
|
|
|
|
|
|
|
|
if (pathsToExpand.length > 0) {
|
|
|
|
|
expandPaths(root, pathsToExpand);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (const path of pathsToExpand) {
|
|
|
|
|
if (!loadedDirsRef.current.has(path)) {
|
|
|
|
|
await loadDirectory(path);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}, [expandPaths, loadDirectory, root]);
|
2026-02-01 21:43:49 +02:00
|
|
|
|
|
|
|
|
const getNextOpenFile = React.useCallback((path: string, filesList: FileNode[]) => {
|
|
|
|
|
const index = filesList.findIndex((file) => file.path === path);
|
|
|
|
|
if (index === -1 || filesList.length <= 1) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
return filesList[index + 1] ?? filesList[index - 1] ?? null;
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const handleSelectFile = React.useCallback(async (node: FileNode) => {
|
|
|
|
|
if (skipDirtyOnceRef.current) {
|
|
|
|
|
skipDirtyOnceRef.current = false;
|
|
|
|
|
} else if (isDirty) {
|
|
|
|
|
setConfirmDiscardOpen(true);
|
|
|
|
|
pendingSelectFileRef.current = node;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (root) {
|
|
|
|
|
setSelectedPath(root, node.path);
|
|
|
|
|
addOpenPath(root, node.path);
|
2026-02-09 11:50:21 +02:00
|
|
|
void ensurePathVisible(node.path, false);
|
2026-02-01 21:43:49 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setFileError(null);
|
|
|
|
|
setDesktopImageSrc('');
|
|
|
|
|
setFileContent('');
|
|
|
|
|
setDraftContent('');
|
|
|
|
|
setLoadedFilePath(null);
|
|
|
|
|
if (isMobile) {
|
|
|
|
|
setShowMobilePageContent(true);
|
|
|
|
|
}
|
2026-02-09 11:50:21 +02:00
|
|
|
}, [addOpenPath, ensurePathVisible, isDirty, isMobile, root, setSelectedPath]);
|
|
|
|
|
|
|
|
|
|
React.useEffect(() => {
|
|
|
|
|
if (!selectedFile?.path) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void ensurePathVisible(selectedFile.path, false);
|
|
|
|
|
}, [ensurePathVisible, selectedFile?.path]);
|
2026-02-01 21:43:49 +02:00
|
|
|
|
|
|
|
|
React.useEffect(() => {
|
|
|
|
|
if (!selectedFile) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (loadedFilePath === selectedFile.path) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Selection changes are guarded; this effect is also what restores persisted tabs on mount.
|
|
|
|
|
void loadSelectedFile(selectedFile);
|
|
|
|
|
}, [loadSelectedFile, loadedFilePath, selectedFile]);
|
2026-01-18 17:29:22 +02:00
|
|
|
|
|
|
|
|
const discardAndContinue = React.useCallback(() => {
|
|
|
|
|
const nextFile = pendingSelectFileRef.current;
|
|
|
|
|
const nextTab = pendingTabRef.current;
|
2026-01-31 17:35:46 -03:00
|
|
|
const closePath = pendingClosePathRef.current;
|
2026-01-18 17:29:22 +02:00
|
|
|
|
|
|
|
|
pendingSelectFileRef.current = null;
|
|
|
|
|
pendingTabRef.current = null;
|
2026-01-31 17:35:46 -03:00
|
|
|
pendingClosePathRef.current = null;
|
2026-01-18 17:29:22 +02:00
|
|
|
|
|
|
|
|
// Allow one guarded navigation (tab/file) without re-opening dialog.
|
|
|
|
|
skipDirtyOnceRef.current = true;
|
|
|
|
|
|
|
|
|
|
setConfirmDiscardOpen(false);
|
|
|
|
|
|
|
|
|
|
// Discard draft by reverting back to last loaded content
|
|
|
|
|
setDraftContent(displayedContent);
|
|
|
|
|
|
2026-01-31 17:35:46 -03:00
|
|
|
if (closePath) {
|
2026-02-01 21:43:49 +02:00
|
|
|
if (root) {
|
|
|
|
|
removeOpenPath(root, closePath);
|
|
|
|
|
}
|
2026-01-31 17:35:46 -03:00
|
|
|
if (selectedFile?.path === closePath) {
|
|
|
|
|
if (nextFile) {
|
|
|
|
|
void handleSelectFile(nextFile);
|
|
|
|
|
} else {
|
2026-02-01 21:43:49 +02:00
|
|
|
if (root) {
|
|
|
|
|
setSelectedPath(root, null);
|
|
|
|
|
}
|
2026-01-31 17:35:46 -03:00
|
|
|
setFileContent('');
|
|
|
|
|
setFileError(null);
|
|
|
|
|
setDesktopImageSrc('');
|
2026-02-01 21:43:49 +02:00
|
|
|
setLoadedFilePath(null);
|
2026-01-31 17:35:46 -03:00
|
|
|
if (isMobile) {
|
|
|
|
|
setShowMobilePageContent(false);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-18 17:29:22 +02:00
|
|
|
if (nextFile) {
|
|
|
|
|
void handleSelectFile(nextFile);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (nextTab) {
|
|
|
|
|
setMainTabGuard(null);
|
|
|
|
|
useUIStore.getState().setActiveMainTab(nextTab);
|
|
|
|
|
}
|
2026-02-01 21:43:49 +02:00
|
|
|
}, [displayedContent, handleSelectFile, isMobile, removeOpenPath, root, selectedFile?.path, setMainTabGuard, setSelectedPath]);
|
2026-01-18 17:29:22 +02:00
|
|
|
|
|
|
|
|
const saveAndContinue = React.useCallback(async () => {
|
|
|
|
|
const nextFile = pendingSelectFileRef.current;
|
|
|
|
|
const nextTab = pendingTabRef.current;
|
2026-01-31 17:35:46 -03:00
|
|
|
const closePath = pendingClosePathRef.current;
|
2026-01-18 17:29:22 +02:00
|
|
|
|
|
|
|
|
pendingSelectFileRef.current = null;
|
|
|
|
|
pendingTabRef.current = null;
|
2026-01-31 17:35:46 -03:00
|
|
|
pendingClosePathRef.current = null;
|
2026-01-18 17:29:22 +02:00
|
|
|
|
|
|
|
|
// We'll proceed after saving; suppress guard reopening.
|
|
|
|
|
skipDirtyOnceRef.current = true;
|
|
|
|
|
|
|
|
|
|
setConfirmDiscardOpen(false);
|
|
|
|
|
|
|
|
|
|
await saveDraft();
|
|
|
|
|
|
2026-01-31 17:35:46 -03:00
|
|
|
if (closePath) {
|
2026-02-01 21:43:49 +02:00
|
|
|
if (root) {
|
|
|
|
|
removeOpenPath(root, closePath);
|
|
|
|
|
}
|
2026-01-31 17:35:46 -03:00
|
|
|
if (selectedFile?.path === closePath) {
|
|
|
|
|
if (nextFile) {
|
|
|
|
|
await handleSelectFile(nextFile);
|
|
|
|
|
} else {
|
2026-02-01 21:43:49 +02:00
|
|
|
if (root) {
|
|
|
|
|
setSelectedPath(root, null);
|
|
|
|
|
}
|
2026-01-31 17:35:46 -03:00
|
|
|
setFileContent('');
|
|
|
|
|
setFileError(null);
|
|
|
|
|
setDesktopImageSrc('');
|
2026-02-01 21:43:49 +02:00
|
|
|
setLoadedFilePath(null);
|
2026-01-31 17:35:46 -03:00
|
|
|
if (isMobile) {
|
|
|
|
|
setShowMobilePageContent(false);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-18 17:29:22 +02:00
|
|
|
if (nextFile) {
|
|
|
|
|
await handleSelectFile(nextFile);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (nextTab) {
|
|
|
|
|
setMainTabGuard(null);
|
|
|
|
|
useUIStore.getState().setActiveMainTab(nextTab);
|
|
|
|
|
}
|
2026-02-01 21:43:49 +02:00
|
|
|
}, [handleSelectFile, isMobile, removeOpenPath, root, saveDraft, selectedFile?.path, setMainTabGuard, setSelectedPath]);
|
2026-01-31 17:35:46 -03:00
|
|
|
|
|
|
|
|
const handleCloseFile = React.useCallback((path: string) => {
|
|
|
|
|
const isActive = selectedFile?.path === path;
|
|
|
|
|
const nextFile = getNextOpenFile(path, openFiles);
|
|
|
|
|
|
|
|
|
|
if (isActive && isDirty) {
|
|
|
|
|
setConfirmDiscardOpen(true);
|
|
|
|
|
pendingSelectFileRef.current = nextFile;
|
|
|
|
|
pendingClosePathRef.current = path;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-01 21:43:49 +02:00
|
|
|
if (root) {
|
|
|
|
|
removeOpenPath(root, path);
|
|
|
|
|
}
|
2026-01-31 17:35:46 -03:00
|
|
|
|
|
|
|
|
if (!isActive) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (nextFile) {
|
|
|
|
|
void handleSelectFile(nextFile);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-01 21:43:49 +02:00
|
|
|
if (root) {
|
|
|
|
|
setSelectedPath(root, null);
|
|
|
|
|
}
|
2026-01-31 17:35:46 -03:00
|
|
|
setFileContent('');
|
|
|
|
|
setFileError(null);
|
|
|
|
|
setDesktopImageSrc('');
|
2026-02-01 21:43:49 +02:00
|
|
|
setLoadedFilePath(null);
|
2026-01-31 17:35:46 -03:00
|
|
|
if (isMobile) {
|
|
|
|
|
setShowMobilePageContent(false);
|
|
|
|
|
}
|
2026-02-01 21:43:49 +02:00
|
|
|
}, [getNextOpenFile, handleSelectFile, isDirty, isMobile, openFiles, removeOpenPath, root, selectedFile?.path, setSelectedPath]);
|
2026-01-15 20:19:06 +02:00
|
|
|
|
2026-02-08 22:24:25 -03:00
|
|
|
const getFileStatus = React.useCallback((path: string): FileStatus | null => {
|
|
|
|
|
// Check open status
|
|
|
|
|
if (openPaths.includes(path)) return 'open';
|
|
|
|
|
|
|
|
|
|
// Check git status
|
|
|
|
|
if (gitStatus?.files) {
|
|
|
|
|
const relative = path.startsWith(root + '/') ? path.slice(root.length + 1) : path;
|
|
|
|
|
const file = gitStatus.files.find(f => f.path === relative);
|
|
|
|
|
if (file) {
|
|
|
|
|
if (file.index === 'A' || file.working_dir === '?') return 'git-added';
|
|
|
|
|
if (file.index === 'D') return 'git-deleted';
|
|
|
|
|
if (file.index === 'M' || file.working_dir === 'M') return 'git-modified';
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
}, [openPaths, gitStatus, root]);
|
|
|
|
|
|
|
|
|
|
const getFolderBadge = React.useCallback((dirPath: string): { modified: number; added: number } | null => {
|
|
|
|
|
if (!gitStatus?.files) return null;
|
|
|
|
|
const relativeDir = dirPath.startsWith(root + '/') ? dirPath.slice(root.length + 1) : dirPath;
|
|
|
|
|
const prefix = relativeDir ? `${relativeDir}/` : '';
|
|
|
|
|
|
|
|
|
|
let modified = 0, added = 0;
|
|
|
|
|
for (const f of gitStatus.files) {
|
|
|
|
|
if (f.path.startsWith(prefix)) {
|
|
|
|
|
if (f.index === 'M' || f.working_dir === 'M') modified++;
|
|
|
|
|
if (f.index === 'A' || f.working_dir === '?') added++;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return modified + added > 0 ? { modified, added } : null;
|
|
|
|
|
}, [gitStatus, root]);
|
|
|
|
|
|
2026-01-15 20:19:06 +02:00
|
|
|
const toggleDirectory = React.useCallback(async (dirPath: string) => {
|
|
|
|
|
const normalized = normalizePath(dirPath);
|
2026-02-08 22:24:25 -03:00
|
|
|
if (!root) return;
|
|
|
|
|
|
|
|
|
|
toggleExpandedPath(root, normalized);
|
2026-01-15 20:19:06 +02:00
|
|
|
|
|
|
|
|
if (!loadedDirsRef.current.has(normalized)) {
|
|
|
|
|
await loadDirectory(normalized);
|
|
|
|
|
}
|
2026-02-08 22:24:25 -03:00
|
|
|
}, [loadDirectory, root, toggleExpandedPath]);
|
|
|
|
|
|
|
|
|
|
const handleBreadcrumbNavigate = React.useCallback((dirPath: string) => {
|
|
|
|
|
if (!root) return;
|
2026-02-09 11:50:21 +02:00
|
|
|
if (searchQuery.trim().length > 0) {
|
|
|
|
|
setSearchQuery('');
|
|
|
|
|
}
|
|
|
|
|
if (isMobile) {
|
|
|
|
|
setShowMobilePageContent(false);
|
|
|
|
|
}
|
|
|
|
|
void ensurePathVisible(dirPath, true);
|
|
|
|
|
}, [ensurePathVisible, isMobile, root, searchQuery]);
|
2026-01-15 20:19:06 +02:00
|
|
|
|
|
|
|
|
const renderTree = React.useCallback((dirPath: string, depth: number): React.ReactNode => {
|
|
|
|
|
const nodes = childrenByDir[dirPath] ?? [];
|
|
|
|
|
|
2026-01-22 12:21:19 +02:00
|
|
|
return nodes.map((node, index) => {
|
2026-01-15 20:19:06 +02:00
|
|
|
const isDir = node.type === 'directory';
|
2026-02-08 22:24:25 -03:00
|
|
|
const isExpanded = isDir && expandedPaths.includes(node.path);
|
2026-01-15 20:19:06 +02:00
|
|
|
const isActive = selectedFile?.path === node.path;
|
|
|
|
|
const isLoading = isDir && inFlightDirsRef.current.has(node.path);
|
2026-01-22 12:21:19 +02:00
|
|
|
const isLast = index === nodes.length - 1;
|
2026-01-15 20:19:06 +02:00
|
|
|
|
|
|
|
|
return (
|
2026-01-22 12:21:19 +02:00
|
|
|
<li key={node.path} className="relative">
|
|
|
|
|
{depth > 0 && (
|
|
|
|
|
<>
|
|
|
|
|
<span className="absolute top-3.5 left-[-12px] w-3 h-px bg-border/40" />
|
|
|
|
|
{isLast && (
|
|
|
|
|
<span className="absolute top-3.5 bottom-0 left-[-13px] w-[2px] bg-background" />
|
|
|
|
|
)}
|
|
|
|
|
</>
|
|
|
|
|
)}
|
2026-02-08 22:24:25 -03:00
|
|
|
<FileRow
|
|
|
|
|
node={node}
|
|
|
|
|
isExpanded={isExpanded}
|
|
|
|
|
isActive={isActive}
|
|
|
|
|
isLoading={isLoading}
|
|
|
|
|
isMobile={isMobile}
|
|
|
|
|
status={!isDir ? getFileStatus(node.path) : undefined}
|
|
|
|
|
badge={isDir ? getFolderBadge(node.path) : undefined}
|
|
|
|
|
permissions={{ canRename, canCreateFile, canCreateFolder, canDelete }}
|
|
|
|
|
contextMenuPath={contextMenuPath}
|
|
|
|
|
setContextMenuPath={setContextMenuPath}
|
|
|
|
|
onSelect={handleSelectFile}
|
|
|
|
|
onToggle={toggleDirectory}
|
|
|
|
|
onOpenDialog={handleOpenDialog}
|
|
|
|
|
/>
|
2026-01-15 20:19:06 +02:00
|
|
|
{isDir && isExpanded && (
|
2026-01-22 12:21:19 +02:00
|
|
|
<ul className="flex flex-col gap-1 ml-3 pl-3 border-l border-border/40 relative">
|
2026-01-15 20:19:06 +02:00
|
|
|
{renderTree(node.path, depth + 1)}
|
|
|
|
|
</ul>
|
|
|
|
|
)}
|
|
|
|
|
</li>
|
|
|
|
|
);
|
|
|
|
|
});
|
2026-02-08 22:24:25 -03:00
|
|
|
}, [childrenByDir, expandedPaths, handleSelectFile, selectedFile?.path, toggleDirectory, handleOpenDialog, canCreateFile, canCreateFolder, canRename, canDelete, contextMenuPath, setContextMenuPath, isMobile, getFileStatus, getFolderBadge]);
|
2026-01-15 20:19:06 +02:00
|
|
|
|
|
|
|
|
const isSelectedImage = Boolean(selectedFile?.path && isImageFile(selectedFile.path));
|
|
|
|
|
const isSelectedSvg = Boolean(selectedFile?.path && selectedFile.path.toLowerCase().endsWith('.svg'));
|
2026-01-31 17:35:46 -03:00
|
|
|
const getDisplayPath = React.useCallback((path: string): string => {
|
|
|
|
|
if (!path) return '';
|
|
|
|
|
const normalizedFilePath = normalizePath(path);
|
2026-02-09 11:50:21 +02:00
|
|
|
if (root && isPathWithinRoot(normalizedFilePath, root)) {
|
2026-01-15 20:19:06 +02:00
|
|
|
const relative = normalizedFilePath.slice(root.length);
|
|
|
|
|
return relative.startsWith('/') ? relative.slice(1) : relative;
|
|
|
|
|
}
|
|
|
|
|
return normalizedFilePath;
|
2026-01-31 17:35:46 -03:00
|
|
|
}, [root]);
|
2026-01-15 20:19:06 +02:00
|
|
|
|
2026-01-31 17:35:46 -03:00
|
|
|
const displaySelectedPath = React.useMemo(() => {
|
|
|
|
|
if (!selectedFile?.path) return '';
|
|
|
|
|
return getDisplayPath(selectedFile.path);
|
|
|
|
|
}, [getDisplayPath, selectedFile?.path]);
|
2026-01-15 20:19:06 +02:00
|
|
|
|
|
|
|
|
const canCopy = Boolean(selectedFile && (!isSelectedImage || isSelectedSvg) && fileContent.length > 0);
|
2026-01-18 17:29:22 +02:00
|
|
|
const canCopyPath = Boolean(selectedFile && displaySelectedPath.length > 0);
|
|
|
|
|
const canEdit = Boolean(selectedFile && !isSelectedImage && files.writeFile && fileContent.length <= MAX_VIEW_CHARS);
|
|
|
|
|
|
2026-02-11 23:49:10 -08:00
|
|
|
const nudgeEditorSelectionAboveKeyboard = React.useCallback((view: EditorView | null) => {
|
|
|
|
|
if (!isMobile || !view || !view.hasFocus || typeof window === 'undefined') {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const viewport = window.visualViewport;
|
|
|
|
|
if (!viewport) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const rootStyles = getComputedStyle(document.documentElement);
|
|
|
|
|
const keyboardInset = Number.parseFloat(rootStyles.getPropertyValue('--oc-keyboard-inset')) || 0;
|
|
|
|
|
const keyboardHomeIndicator = Number.parseFloat(rootStyles.getPropertyValue('--oc-keyboard-home-indicator')) || 0;
|
|
|
|
|
const occludedBottom = keyboardInset + keyboardHomeIndicator;
|
|
|
|
|
if (occludedBottom <= 0) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const head = view.state.selection.main.head;
|
|
|
|
|
const cursorRect = view.coordsAtPos(head);
|
|
|
|
|
if (!cursorRect) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const visibleBottom = Math.round(viewport.offsetTop + viewport.height);
|
|
|
|
|
const clearance = 20;
|
|
|
|
|
const overlap = cursorRect.bottom + clearance - visibleBottom;
|
|
|
|
|
if (overlap <= 0) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
view.scrollDOM.scrollTop += overlap;
|
|
|
|
|
}, [isMobile]);
|
|
|
|
|
|
|
|
|
|
React.useEffect(() => {
|
|
|
|
|
if (!isMobile || typeof window === 'undefined') {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const runNudge = () => {
|
|
|
|
|
window.requestAnimationFrame(() => {
|
|
|
|
|
nudgeEditorSelectionAboveKeyboard(editorViewRef.current);
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const viewport = window.visualViewport;
|
|
|
|
|
viewport?.addEventListener('resize', runNudge);
|
|
|
|
|
viewport?.addEventListener('scroll', runNudge);
|
|
|
|
|
document.addEventListener('selectionchange', runNudge);
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
viewport?.removeEventListener('resize', runNudge);
|
|
|
|
|
viewport?.removeEventListener('scroll', runNudge);
|
|
|
|
|
document.removeEventListener('selectionchange', runNudge);
|
|
|
|
|
};
|
|
|
|
|
}, [isMobile, nudgeEditorSelectionAboveKeyboard]);
|
|
|
|
|
|
2026-01-18 17:29:22 +02:00
|
|
|
const editorExtensions = React.useMemo(() => {
|
|
|
|
|
if (!selectedFile?.path) {
|
|
|
|
|
return [createFlexokiCodeMirrorTheme(currentTheme)];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const extensions = [createFlexokiCodeMirrorTheme(currentTheme)];
|
|
|
|
|
const language = languageByExtension(selectedFile.path);
|
|
|
|
|
if (language) {
|
|
|
|
|
extensions.push(language);
|
|
|
|
|
}
|
|
|
|
|
if (wrapLines) {
|
|
|
|
|
extensions.push(EditorView.lineWrapping);
|
|
|
|
|
}
|
2026-02-11 23:49:10 -08:00
|
|
|
if (isMobile) {
|
|
|
|
|
extensions.push(EditorView.updateListener.of((update) => {
|
|
|
|
|
if (!update.view.hasFocus) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (!(update.selectionSet || update.focusChanged || update.viewportChanged || update.geometryChanged)) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
window.requestAnimationFrame(() => {
|
|
|
|
|
nudgeEditorSelectionAboveKeyboard(update.view);
|
|
|
|
|
});
|
|
|
|
|
}));
|
|
|
|
|
}
|
2026-01-18 17:29:22 +02:00
|
|
|
return extensions;
|
2026-02-11 23:49:10 -08:00
|
|
|
}, [currentTheme, selectedFile?.path, wrapLines, isMobile, nudgeEditorSelectionAboveKeyboard]);
|
2026-01-15 20:19:06 +02:00
|
|
|
|
|
|
|
|
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)}`))
|
|
|
|
|
: '';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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]);
|
|
|
|
|
|
2026-01-22 12:21:19 +02:00
|
|
|
const renderDialogs = () => (
|
|
|
|
|
<Dialog open={!!activeDialog} onOpenChange={(open) => !open && setActiveDialog(null)}>
|
|
|
|
|
<DialogContent>
|
|
|
|
|
<DialogHeader>
|
|
|
|
|
<DialogTitle>
|
|
|
|
|
{activeDialog === 'createFile' && 'Create File'}
|
|
|
|
|
{activeDialog === 'createFolder' && 'Create Folder'}
|
|
|
|
|
{activeDialog === 'rename' && 'Rename'}
|
|
|
|
|
{activeDialog === 'delete' && 'Delete'}
|
|
|
|
|
</DialogTitle>
|
|
|
|
|
<DialogDescription>
|
|
|
|
|
{activeDialog === 'createFile' && `Create a new file in ${dialogData?.path ?? 'root'}`}
|
|
|
|
|
{activeDialog === 'createFolder' && `Create a new folder in ${dialogData?.path ?? 'root'}`}
|
|
|
|
|
{activeDialog === 'rename' && `Rename ${dialogData?.name}`}
|
|
|
|
|
{activeDialog === 'delete' && `Are you sure you want to delete ${dialogData?.name}? This action cannot be undone.`}
|
|
|
|
|
</DialogDescription>
|
|
|
|
|
</DialogHeader>
|
|
|
|
|
|
|
|
|
|
{activeDialog !== 'delete' && (
|
|
|
|
|
<div className="py-4">
|
|
|
|
|
<Input
|
|
|
|
|
value={dialogInputValue}
|
|
|
|
|
onChange={(e) => setDialogInputValue(e.target.value)}
|
|
|
|
|
placeholder={activeDialog === 'rename' ? 'New name' : 'Name'}
|
|
|
|
|
onKeyDown={(e) => {
|
|
|
|
|
if (e.key === 'Enter') {
|
|
|
|
|
void handleDialogSubmit();
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
autoFocus
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
<DialogFooter>
|
|
|
|
|
<Button variant="outline" onClick={() => setActiveDialog(null)} disabled={isDialogSubmitting}>
|
|
|
|
|
Cancel
|
|
|
|
|
</Button>
|
|
|
|
|
<Button
|
|
|
|
|
variant={activeDialog === 'delete' ? 'destructive' : 'default'}
|
|
|
|
|
onClick={() => void handleDialogSubmit()}
|
|
|
|
|
disabled={isDialogSubmitting || (activeDialog !== 'delete' && !dialogInputValue.trim())}
|
|
|
|
|
>
|
|
|
|
|
{isDialogSubmitting ? <RiLoader4Line className="animate-spin" /> : (
|
|
|
|
|
activeDialog === 'delete' ? 'Delete' : 'Confirm'
|
|
|
|
|
)}
|
|
|
|
|
</Button>
|
|
|
|
|
</DialogFooter>
|
2026-02-08 22:24:25 -03:00
|
|
|
</DialogContent>
|
|
|
|
|
</Dialog>
|
|
|
|
|
);
|
2026-01-22 12:21:19 +02:00
|
|
|
|
2026-02-08 22:24:25 -03:00
|
|
|
const blockWidgets = React.useMemo(() => {
|
|
|
|
|
if (!selectedFile) return [];
|
|
|
|
|
|
|
|
|
|
const sessionKey = currentSessionId ?? 'draft';
|
|
|
|
|
const sessionDrafts = allDrafts[sessionKey] ?? [];
|
|
|
|
|
// Filter drafts for current file
|
|
|
|
|
const fileDrafts = sessionDrafts.filter(
|
2026-02-09 11:52:41 -03:00
|
|
|
(d) => d.source === 'file' && d.fileLabel === selectedFile.path
|
2026-02-08 22:24:25 -03:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const widgets: BlockWidgetDef[] = [];
|
|
|
|
|
|
|
|
|
|
// Add cards for existing drafts
|
|
|
|
|
fileDrafts.forEach((draft) => {
|
|
|
|
|
const isEditing = editingDraftId === draft.id;
|
|
|
|
|
|
|
|
|
|
if (isEditing) {
|
|
|
|
|
widgets.push({
|
|
|
|
|
afterLine: draft.endLine,
|
|
|
|
|
id: `edit-${draft.id}`,
|
|
|
|
|
content: (
|
|
|
|
|
<InlineCommentInput
|
|
|
|
|
initialText={draft.text}
|
|
|
|
|
lineRange={{ start: draft.startLine, end: draft.endLine }}
|
|
|
|
|
onSave={(text) => handleSaveComment(text, { start: draft.startLine, end: draft.endLine })}
|
|
|
|
|
onCancel={() => setEditingDraftId(null)}
|
|
|
|
|
isEditing={true}
|
|
|
|
|
/>
|
|
|
|
|
),
|
|
|
|
|
});
|
|
|
|
|
} else {
|
|
|
|
|
widgets.push({
|
|
|
|
|
afterLine: draft.endLine,
|
|
|
|
|
id: `card-${draft.id}`,
|
|
|
|
|
content: (
|
|
|
|
|
<InlineCommentCard
|
|
|
|
|
draft={draft}
|
|
|
|
|
onEdit={() => {
|
|
|
|
|
setEditingDraftId(draft.id);
|
2026-01-15 20:19:06 +02:00
|
|
|
setLineSelection(null);
|
2026-02-08 22:24:25 -03:00
|
|
|
}}
|
|
|
|
|
onDelete={() => removeDraft(sessionKey, draft.id)}
|
|
|
|
|
/>
|
|
|
|
|
),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Add input for new comment
|
2026-02-09 11:52:19 -03:00
|
|
|
if (lineSelection && !editingDraftId && !isDragging) {
|
2026-02-08 22:24:25 -03:00
|
|
|
widgets.push({
|
|
|
|
|
afterLine: lineSelection.end,
|
2026-02-09 11:52:19 -03:00
|
|
|
id: 'files-new-comment-input',
|
2026-02-08 22:24:25 -03:00
|
|
|
content: (
|
|
|
|
|
<InlineCommentInput
|
|
|
|
|
lineRange={lineSelection}
|
|
|
|
|
onSave={(text) => handleSaveComment(text)}
|
|
|
|
|
onCancel={() => setLineSelection(null)}
|
2026-01-15 20:19:06 +02:00
|
|
|
/>
|
2026-02-08 22:24:25 -03:00
|
|
|
),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return widgets;
|
2026-02-09 11:52:19 -03:00
|
|
|
}, [selectedFile, currentSessionId, allDrafts, editingDraftId, lineSelection, handleSaveComment, removeDraft, isDragging]);
|
2026-01-15 20:19:06 +02:00
|
|
|
|
|
|
|
|
const fileViewer = (
|
|
|
|
|
<div
|
2026-01-18 17:29:22 +02:00
|
|
|
className="relative flex h-full min-h-0 min-w-0 w-full flex-col overflow-hidden"
|
2026-01-15 20:19:06 +02:00
|
|
|
>
|
2026-01-18 17:29:22 +02:00
|
|
|
<Dialog open={confirmDiscardOpen} onOpenChange={(open) => {
|
|
|
|
|
// Intentionally no "cancel" action. Keep dialog modal.
|
|
|
|
|
if (!open) {
|
|
|
|
|
setConfirmDiscardOpen(true);
|
|
|
|
|
}
|
|
|
|
|
}}>
|
|
|
|
|
<DialogContent showCloseButton={false} className="max-w-md">
|
|
|
|
|
<DialogHeader>
|
|
|
|
|
<DialogTitle>Unsaved changes</DialogTitle>
|
|
|
|
|
<DialogDescription>
|
|
|
|
|
Save your edits before continuing?
|
|
|
|
|
</DialogDescription>
|
|
|
|
|
</DialogHeader>
|
|
|
|
|
<DialogFooter>
|
|
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
onClick={() => void saveAndContinue()}
|
|
|
|
|
disabled={isSaving}
|
|
|
|
|
className="border-[var(--status-success-border)] bg-[var(--status-success-background)] text-[var(--status-success)] hover:bg-[rgb(var(--status-success)/0.2)]"
|
|
|
|
|
>
|
|
|
|
|
Save changes
|
|
|
|
|
</Button>
|
|
|
|
|
<Button variant="destructive" onClick={discardAndContinue}>Discard</Button>
|
|
|
|
|
</DialogFooter>
|
|
|
|
|
</DialogContent>
|
|
|
|
|
</Dialog>
|
|
|
|
|
<div className="flex min-w-0 items-center gap-2 border-b border-border/40 px-3 py-1.5 flex-shrink-0">
|
2026-01-15 20:19:06 +02:00
|
|
|
{isMobile && showMobilePageContent && (
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => setShowMobilePageContent(false)}
|
|
|
|
|
aria-label="Back"
|
|
|
|
|
className="inline-flex h-9 w-9 flex-shrink-0 items-center justify-center p-2 text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
|
|
|
|
>
|
|
|
|
|
<RiArrowLeftSLine className="h-5 w-5" />
|
|
|
|
|
</button>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
<div className="min-w-0 flex-1">
|
2026-01-31 17:35:46 -03:00
|
|
|
{isMobile ? (
|
|
|
|
|
selectedFile ? (
|
|
|
|
|
<DropdownMenu>
|
|
|
|
|
<DropdownMenuTrigger asChild>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className="inline-flex min-w-0 max-w-full items-center gap-1 text-left typography-ui-label font-medium"
|
|
|
|
|
aria-label="Open files"
|
|
|
|
|
>
|
|
|
|
|
<span className="min-w-0 flex-1 truncate">{selectedFile.name}</span>
|
|
|
|
|
<RiArrowDownSLine className="h-4 w-4 flex-shrink-0 text-muted-foreground" />
|
|
|
|
|
</button>
|
|
|
|
|
</DropdownMenuTrigger>
|
|
|
|
|
<DropdownMenuContent align="start" className="min-w-[16rem]">
|
|
|
|
|
{openFiles.map((file) => {
|
|
|
|
|
const isActive = selectedFile?.path === file.path;
|
|
|
|
|
return (
|
|
|
|
|
<DropdownMenuItem
|
|
|
|
|
key={file.path}
|
|
|
|
|
onSelect={(event) => {
|
|
|
|
|
const target = event.target as HTMLElement;
|
|
|
|
|
if (target.closest('[data-close-open-file]')) {
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (!isActive) {
|
|
|
|
|
void handleSelectFile(file);
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
className={cn(
|
|
|
|
|
'flex items-center justify-between gap-2',
|
2026-02-01 21:43:49 +02:00
|
|
|
isActive && 'bg-[var(--interactive-selection)] text-[var(--interactive-selection-foreground)]'
|
2026-01-31 17:35:46 -03:00
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
<span className="min-w-0 flex-1 truncate">
|
|
|
|
|
{file.name}
|
|
|
|
|
</span>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
data-close-open-file
|
|
|
|
|
onPointerDown={(event) => {
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
event.stopPropagation();
|
|
|
|
|
}}
|
|
|
|
|
onClick={(event) => {
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
event.stopPropagation();
|
|
|
|
|
handleCloseFile(file.path);
|
|
|
|
|
}}
|
2026-02-01 21:43:49 +02:00
|
|
|
className="inline-flex h-6 w-6 items-center justify-center rounded-md text-[var(--surface-muted-foreground)] hover:text-[var(--surface-foreground)]"
|
2026-01-31 17:35:46 -03:00
|
|
|
aria-label={`Close ${file.name}`}
|
|
|
|
|
>
|
|
|
|
|
<RiCloseLine className="h-3.5 w-3.5" />
|
|
|
|
|
</button>
|
|
|
|
|
</DropdownMenuItem>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</DropdownMenuContent>
|
|
|
|
|
</DropdownMenu>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="typography-ui-label font-medium truncate">Select a file</div>
|
|
|
|
|
)
|
|
|
|
|
) : (
|
|
|
|
|
openFiles.length > 0 ? (
|
|
|
|
|
<div className="flex min-w-0 flex-col gap-1">
|
|
|
|
|
<div className="flex min-w-0 items-center gap-1 overflow-x-auto">
|
|
|
|
|
{openFiles.map((file) => {
|
|
|
|
|
const isActive = selectedFile?.path === file.path;
|
|
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
key={file.path}
|
|
|
|
|
title={getDisplayPath(file.path)}
|
|
|
|
|
className={cn(
|
2026-02-01 21:43:49 +02:00
|
|
|
'group inline-flex items-center gap-1 rounded-md border px-2 py-1 typography-ui-label transition-colors whitespace-nowrap',
|
2026-01-31 17:35:46 -03:00
|
|
|
isActive
|
2026-02-01 21:43:49 +02:00
|
|
|
? 'bg-[var(--interactive-selection)] border-[var(--primary-muted)] text-[var(--interactive-selection-foreground)]'
|
|
|
|
|
: 'bg-transparent border-[var(--interactive-border)] text-[var(--surface-muted-foreground)] hover:bg-[var(--interactive-hover)] hover:text-[var(--surface-foreground)]'
|
2026-01-31 17:35:46 -03:00
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => {
|
|
|
|
|
if (!isActive) {
|
|
|
|
|
void handleSelectFile(file);
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
className="max-w-[12rem] truncate text-left"
|
|
|
|
|
>
|
|
|
|
|
{file.name}
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={(event) => {
|
|
|
|
|
event.stopPropagation();
|
|
|
|
|
handleCloseFile(file.path);
|
|
|
|
|
}}
|
2026-02-01 21:43:49 +02:00
|
|
|
className={cn(
|
|
|
|
|
'rounded-sm p-0.5 text-[var(--surface-muted-foreground)] hover:text-[var(--surface-foreground)]',
|
|
|
|
|
!isActive && 'opacity-0 group-hover:opacity-100'
|
|
|
|
|
)}
|
2026-01-31 17:35:46 -03:00
|
|
|
aria-label={`Close ${file.name}`}
|
|
|
|
|
>
|
2026-02-01 21:43:49 +02:00
|
|
|
<RiCloseLine size={14} />
|
2026-01-31 17:35:46 -03:00
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
{selectedFile && (
|
2026-02-08 22:24:25 -03:00
|
|
|
<FileBreadcrumbs
|
|
|
|
|
path={displaySelectedPath}
|
|
|
|
|
root={root}
|
|
|
|
|
onNavigate={handleBreadcrumbNavigate}
|
|
|
|
|
/>
|
2026-01-31 17:35:46 -03:00
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="typography-ui-label font-medium truncate">Select a file</div>
|
|
|
|
|
)
|
2026-01-15 20:19:06 +02:00
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-01-18 17:29:22 +02:00
|
|
|
<div className="flex items-center gap-1">
|
|
|
|
|
{canEdit && (
|
|
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={() => void saveDraft()}
|
|
|
|
|
disabled={!isDirty || isSaving}
|
|
|
|
|
className="h-5 w-5 p-0 text-[color:var(--status-success)] opacity-70 hover:opacity-100"
|
|
|
|
|
title={`Save (${getModifierLabel()}+S)`}
|
|
|
|
|
aria-label={`Save (${getModifierLabel()}+S)`}
|
|
|
|
|
>
|
|
|
|
|
{isSaving ? (
|
|
|
|
|
<RiLoader4Line className="h-4 w-4 animate-spin" />
|
|
|
|
|
) : (
|
|
|
|
|
<RiSave3Line className="h-4 w-4" />
|
|
|
|
|
)}
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{canEdit && selectedFile && !isSelectedImage && (
|
|
|
|
|
<span aria-hidden="true" className="mx-1 h-4 w-px bg-border/60" />
|
|
|
|
|
)}
|
|
|
|
|
|
2026-01-15 20:19:06 +02:00
|
|
|
{selectedFile && !isSelectedImage && (
|
2026-02-08 22:24:25 -03:00
|
|
|
<>
|
|
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={() => setWrapLines(!wrapLines)}
|
|
|
|
|
className={cn(
|
|
|
|
|
'h-5 w-5 p-0 transition-opacity',
|
|
|
|
|
wrapLines ? 'text-foreground opacity-100' : 'text-muted-foreground opacity-60 hover:opacity-100'
|
|
|
|
|
)}
|
|
|
|
|
title={wrapLines ? 'Disable line wrap' : 'Enable line wrap'}
|
|
|
|
|
>
|
|
|
|
|
<RiTextWrap className="size-4" />
|
|
|
|
|
</Button>
|
|
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={() => setIsSearchOpen(!isSearchOpen)}
|
|
|
|
|
className={cn(
|
|
|
|
|
'h-5 w-5 p-0 transition-opacity',
|
|
|
|
|
isSearchOpen ? 'text-foreground opacity-100' : 'text-muted-foreground opacity-60 hover:opacity-100'
|
|
|
|
|
)}
|
|
|
|
|
title="Find in file"
|
|
|
|
|
>
|
|
|
|
|
<RiSearchLine className="size-4" />
|
|
|
|
|
</Button>
|
|
|
|
|
</>
|
2026-01-15 20:19:06 +02:00
|
|
|
)}
|
|
|
|
|
|
2026-01-27 15:15:00 +08:00
|
|
|
{(canCopy || canCopyPath || (selectedFile && isMarkdownFile(selectedFile.path))) && (canEdit || (selectedFile && !isSelectedImage)) && (
|
2026-01-18 17:29:22 +02:00
|
|
|
<span aria-hidden="true" className="mx-1 h-4 w-px bg-border/60" />
|
|
|
|
|
)}
|
|
|
|
|
|
2026-01-27 15:15:00 +08:00
|
|
|
{selectedFile && isMarkdownFile(selectedFile.path) && (
|
|
|
|
|
<PreviewToggleButton
|
|
|
|
|
currentMode={getMdViewMode()}
|
|
|
|
|
onToggle={() => saveMdViewMode(getMdViewMode() === 'preview' ? 'edit' : 'preview')}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-01-15 20:19:06 +02:00
|
|
|
{canCopy && (
|
|
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={async () => {
|
|
|
|
|
try {
|
|
|
|
|
await navigator.clipboard.writeText(fileContent);
|
2026-01-25 15:52:02 +02:00
|
|
|
setCopiedContent(true);
|
|
|
|
|
if (copiedContentTimeoutRef.current !== null) {
|
|
|
|
|
window.clearTimeout(copiedContentTimeoutRef.current);
|
|
|
|
|
}
|
|
|
|
|
copiedContentTimeoutRef.current = window.setTimeout(() => {
|
|
|
|
|
setCopiedContent(false);
|
|
|
|
|
}, 1200);
|
2026-01-15 20:19:06 +02:00
|
|
|
} catch {
|
|
|
|
|
toast.error('Copy failed');
|
|
|
|
|
}
|
|
|
|
|
}}
|
2026-01-18 17:29:22 +02:00
|
|
|
className="h-5 w-5 p-0"
|
|
|
|
|
title="Copy file contents"
|
|
|
|
|
aria-label="Copy file contents"
|
2026-01-15 20:19:06 +02:00
|
|
|
>
|
2026-01-25 15:52:02 +02:00
|
|
|
{copiedContent ? (
|
|
|
|
|
<RiCheckLine className="h-4 w-4 text-[color:var(--status-success)]" />
|
|
|
|
|
) : (
|
|
|
|
|
<RiClipboardLine className="h-4 w-4" />
|
|
|
|
|
)}
|
2026-01-18 17:29:22 +02:00
|
|
|
</Button>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{canCopyPath && (
|
|
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={async () => {
|
|
|
|
|
try {
|
|
|
|
|
await navigator.clipboard.writeText(displaySelectedPath);
|
2026-01-25 15:52:02 +02:00
|
|
|
setCopiedPath(true);
|
|
|
|
|
if (copiedPathTimeoutRef.current !== null) {
|
|
|
|
|
window.clearTimeout(copiedPathTimeoutRef.current);
|
|
|
|
|
}
|
|
|
|
|
copiedPathTimeoutRef.current = window.setTimeout(() => {
|
|
|
|
|
setCopiedPath(false);
|
|
|
|
|
}, 1200);
|
2026-01-18 17:29:22 +02:00
|
|
|
} catch {
|
|
|
|
|
toast.error('Copy failed');
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
className="h-5 w-5 p-0"
|
|
|
|
|
title={`Copy file path (${displaySelectedPath})`}
|
|
|
|
|
aria-label={`Copy file path (${displaySelectedPath})`}
|
|
|
|
|
>
|
2026-01-25 15:52:02 +02:00
|
|
|
{copiedPath ? (
|
|
|
|
|
<RiCheckLine className="h-4 w-4 text-[color:var(--status-success)]" />
|
|
|
|
|
) : (
|
|
|
|
|
<RiFileCopy2Line className="h-4 w-4" />
|
|
|
|
|
)}
|
2026-01-15 20:19:06 +02:00
|
|
|
</Button>
|
|
|
|
|
)}
|
2026-01-27 09:23:33 -08:00
|
|
|
|
|
|
|
|
{selectedFile && !isMobile && (
|
|
|
|
|
<>
|
|
|
|
|
<span aria-hidden="true" className="mx-1 h-4 w-px bg-border/60" />
|
|
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={() => setIsFullscreen(!isFullscreen)}
|
|
|
|
|
className="h-5 w-5 p-0"
|
|
|
|
|
title={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
|
|
|
|
|
aria-label={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
|
|
|
|
|
>
|
|
|
|
|
{isFullscreen ? (
|
|
|
|
|
<RiFullscreenExitLine className="h-4 w-4" />
|
|
|
|
|
) : (
|
|
|
|
|
<RiFullscreenLine className="h-4 w-4" />
|
|
|
|
|
)}
|
|
|
|
|
</Button>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
2026-01-15 20:19:06 +02:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-01-18 17:29:22 +02:00
|
|
|
<div className="flex-1 min-h-0 min-w-0 relative">
|
|
|
|
|
<ScrollableOverlay outerClassName="h-full min-w-0" className="h-full min-w-0">
|
2026-01-15 20:19:06 +02:00
|
|
|
{!selectedFile ? (
|
|
|
|
|
<div className="p-3 typography-ui text-muted-foreground">Pick a file from the tree.</div>
|
|
|
|
|
) : fileLoading ? (
|
|
|
|
|
<div className="p-3 flex items-center gap-2 typography-ui text-muted-foreground">
|
|
|
|
|
<RiLoader4Line className="h-4 w-4 animate-spin" />
|
|
|
|
|
Loading…
|
|
|
|
|
</div>
|
|
|
|
|
) : fileError ? (
|
|
|
|
|
<div className="p-3 typography-ui text-[color:var(--status-error)]">{fileError}</div>
|
|
|
|
|
) : isSelectedImage ? (
|
|
|
|
|
<div className="flex h-full items-center justify-center p-3">
|
|
|
|
|
<img
|
|
|
|
|
src={imageSrc}
|
|
|
|
|
alt={selectedFile?.name ?? 'Image'}
|
|
|
|
|
className="max-w-full max-h-[70vh] object-contain rounded-md border border-border/30 bg-primary/10"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
2026-01-27 15:15:00 +08:00
|
|
|
) : selectedFile && isMarkdownFile(selectedFile.path) && getMdViewMode() === 'preview' ? (
|
|
|
|
|
<div className="h-full overflow-auto p-3">
|
|
|
|
|
{fileContent.length > 500 * 1024 && (
|
2026-02-01 18:29:34 +02:00
|
|
|
<div className="mb-3 rounded-md border border-status-warning/20 bg-status-warning/10 px-3 py-2 text-sm text-status-warning">
|
2026-01-27 15:15:00 +08:00
|
|
|
⚠️ This file is large ({Math.round(fileContent.length / 1024)}KB). Preview may be limited.
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
<ErrorBoundary
|
|
|
|
|
fallback={
|
|
|
|
|
<div className="rounded-md border border-destructive/20 bg-destructive/10 px-3 py-2">
|
|
|
|
|
<div className="mb-1 font-medium text-destructive">Preview unavailable</div>
|
|
|
|
|
<div className="text-sm text-muted-foreground">
|
|
|
|
|
Switch to edit mode to fix the issue.
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
<SimpleMarkdownRenderer content={fileContent} className="typography-markdown-body" />
|
|
|
|
|
</ErrorBoundary>
|
|
|
|
|
</div>
|
2026-01-15 20:19:06 +02:00
|
|
|
) : (
|
2026-02-11 23:49:10 -08:00
|
|
|
<div
|
|
|
|
|
className="h-full"
|
|
|
|
|
data-keyboard-avoid="none"
|
|
|
|
|
style={isMobile ? { height: 'calc(100% - var(--oc-keyboard-inset, 0px))' } : undefined}
|
|
|
|
|
>
|
2026-01-18 17:29:22 +02:00
|
|
|
<CodeMirrorEditor
|
|
|
|
|
value={draftContent}
|
|
|
|
|
onChange={setDraftContent}
|
|
|
|
|
extensions={editorExtensions}
|
2026-02-11 23:49:10 -08:00
|
|
|
className="h-full"
|
|
|
|
|
onViewReady={(view) => {
|
|
|
|
|
editorViewRef.current = view;
|
|
|
|
|
window.requestAnimationFrame(() => {
|
|
|
|
|
nudgeEditorSelectionAboveKeyboard(view);
|
|
|
|
|
});
|
|
|
|
|
}}
|
|
|
|
|
onViewDestroy={() => {
|
|
|
|
|
if (editorViewRef.current) {
|
|
|
|
|
editorViewRef.current = null;
|
|
|
|
|
}
|
|
|
|
|
}}
|
2026-02-08 22:24:25 -03:00
|
|
|
enableSearch
|
|
|
|
|
searchOpen={isSearchOpen}
|
|
|
|
|
onSearchOpenChange={setIsSearchOpen}
|
|
|
|
|
blockWidgets={blockWidgets}
|
2026-01-18 17:29:22 +02:00
|
|
|
highlightLines={lineSelection
|
|
|
|
|
? {
|
|
|
|
|
start: Math.min(lineSelection.start, lineSelection.end),
|
|
|
|
|
end: Math.max(lineSelection.start, lineSelection.end),
|
|
|
|
|
}
|
|
|
|
|
: undefined}
|
|
|
|
|
lineNumbersConfig={{
|
|
|
|
|
domEventHandlers: {
|
|
|
|
|
mousedown: (view: EditorView, line: { from: number; to: number }, event: Event) => {
|
|
|
|
|
if (!(event instanceof MouseEvent)) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
if (event.button !== 0) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
|
|
|
|
|
const lineNumber = view.state.doc.lineAt(line.from).number;
|
|
|
|
|
|
|
|
|
|
// Mobile: tap-to-extend selection
|
2026-02-09 11:52:19 -03:00
|
|
|
if (isMobile && lineSelection && !event.shiftKey) {
|
|
|
|
|
const start = Math.min(lineSelection.start, lineSelection.end, lineNumber);
|
|
|
|
|
const end = Math.max(lineSelection.start, lineSelection.end, lineNumber);
|
|
|
|
|
setLineSelection({ start, end });
|
|
|
|
|
isSelectingRef.current = false;
|
|
|
|
|
selectionStartRef.current = null;
|
|
|
|
|
setIsDragging(false);
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
isSelectingRef.current = true;
|
|
|
|
|
selectionStartRef.current = lineNumber;
|
|
|
|
|
setIsDragging(true);
|
|
|
|
|
|
|
|
|
|
if (lineSelection && event.shiftKey) {
|
2026-01-18 17:29:22 +02:00
|
|
|
const start = Math.min(lineSelection.start, lineNumber);
|
|
|
|
|
const end = Math.max(lineSelection.end, lineNumber);
|
|
|
|
|
setLineSelection({ start, end });
|
|
|
|
|
} else {
|
|
|
|
|
setLineSelection({ start: lineNumber, end: lineNumber });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return true;
|
|
|
|
|
},
|
|
|
|
|
mouseover: (view: EditorView, line: { from: number; to: number }, event: Event) => {
|
|
|
|
|
if (!(event instanceof MouseEvent)) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
if (event.buttons !== 1) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
if (!isSelectingRef.current || selectionStartRef.current === null) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const lineNumber = view.state.doc.lineAt(line.from).number;
|
2026-02-09 11:52:19 -03:00
|
|
|
const start = Math.min(selectionStartRef.current, lineNumber);
|
|
|
|
|
const end = Math.max(selectionStartRef.current, lineNumber);
|
|
|
|
|
setLineSelection({ start, end });
|
|
|
|
|
setIsDragging(true);
|
|
|
|
|
return false;
|
|
|
|
|
},
|
|
|
|
|
mouseup: () => {
|
|
|
|
|
isSelectingRef.current = false;
|
|
|
|
|
selectionStartRef.current = null;
|
|
|
|
|
setIsDragging(false);
|
|
|
|
|
return false;
|
|
|
|
|
},
|
2026-01-18 17:29:22 +02:00
|
|
|
},
|
2026-01-15 20:19:06 +02:00
|
|
|
}}
|
2026-01-18 17:29:22 +02:00
|
|
|
/>
|
2026-01-15 20:19:06 +02:00
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</ScrollableOverlay>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const hasTree = Boolean(root && childrenByDir[root]);
|
|
|
|
|
|
|
|
|
|
const treePanel = (
|
|
|
|
|
<section className={cn(
|
|
|
|
|
"flex min-h-0 flex-col overflow-hidden",
|
|
|
|
|
isMobile ? "h-full w-full bg-background" : "h-full rounded-xl border border-border/60 bg-background/70"
|
|
|
|
|
)}>
|
2026-01-20 20:20:17 +07:00
|
|
|
<div className={cn("flex flex-col gap-2 py-2", isMobile ? "px-3" : "px-2")}>
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<div className="relative flex-1 min-w-0">
|
|
|
|
|
<RiSearchLine className="pointer-events-none absolute left-2 top-2 h-4 w-4 text-muted-foreground" />
|
|
|
|
|
<Input
|
|
|
|
|
ref={searchInputRef}
|
|
|
|
|
value={searchQuery}
|
|
|
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
|
|
|
placeholder="Search files…"
|
|
|
|
|
className="h-8 pl-8 pr-8 typography-meta"
|
|
|
|
|
/>
|
|
|
|
|
{searchQuery.trim().length > 0 && (
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
aria-label="Clear search"
|
|
|
|
|
className="absolute right-2 top-2 inline-flex h-4 w-4 items-center justify-center text-muted-foreground hover:text-foreground"
|
|
|
|
|
onClick={() => {
|
|
|
|
|
setSearchQuery('');
|
|
|
|
|
searchInputRef.current?.focus();
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<RiCloseLine className="h-4 w-4" />
|
|
|
|
|
</button>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
2026-01-22 12:21:19 +02:00
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={() => handleOpenDialog('createFile', { path: currentDirectory, type: 'directory' })}
|
|
|
|
|
className="h-8 w-8 p-0 flex-shrink-0"
|
|
|
|
|
title="New File"
|
|
|
|
|
>
|
|
|
|
|
<RiFileAddLine className="h-4 w-4" />
|
|
|
|
|
</Button>
|
|
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={() => handleOpenDialog('createFolder', { path: currentDirectory, type: 'directory' })}
|
|
|
|
|
className="h-8 w-8 p-0 flex-shrink-0"
|
|
|
|
|
title="New Folder"
|
|
|
|
|
>
|
|
|
|
|
<RiFolderAddLine className="h-4 w-4" />
|
|
|
|
|
</Button>
|
2026-01-20 20:20:17 +07:00
|
|
|
<Button variant="ghost" size="sm" onClick={() => void refreshRoot()} className="h-8 w-8 p-0 flex-shrink-0">
|
|
|
|
|
<RiRefreshLine className="h-4 w-4" />
|
|
|
|
|
</Button>
|
2026-01-15 20:19:06 +02:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<ScrollableOverlay outerClassName="flex-1 min-h-0" className={cn("py-2", isMobile ? "px-3" : "px-2")}>
|
|
|
|
|
<ul className="flex flex-col">
|
|
|
|
|
{searching ? (
|
|
|
|
|
<li className="flex items-center gap-1.5 px-2 py-1 typography-meta text-muted-foreground">
|
|
|
|
|
<RiLoader4Line className="h-4 w-4 animate-spin" />
|
|
|
|
|
Searching…
|
|
|
|
|
</li>
|
|
|
|
|
) : searchResults.length > 0 ? (
|
|
|
|
|
searchResults.map((node) => {
|
|
|
|
|
const isActive = selectedFile?.path === node.path;
|
|
|
|
|
return (
|
|
|
|
|
<li key={node.path}>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => void handleSelectFile(node)}
|
|
|
|
|
className={cn(
|
|
|
|
|
'flex w-full items-center gap-1.5 rounded-md px-2 py-1 text-left text-foreground transition-colors',
|
2026-02-01 18:29:34 +02:00
|
|
|
isActive ? 'bg-interactive-selection/70' : 'hover:bg-interactive-hover/40'
|
2026-01-15 20:19:06 +02:00
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
{getFileIcon(node.extension)}
|
|
|
|
|
<span
|
|
|
|
|
className="min-w-0 flex-1 truncate typography-meta"
|
|
|
|
|
style={{ direction: 'rtl', textAlign: 'left' }}
|
|
|
|
|
title={node.path}
|
|
|
|
|
>
|
|
|
|
|
{node.relativePath ?? node.path}
|
|
|
|
|
</span>
|
|
|
|
|
</button>
|
|
|
|
|
</li>
|
|
|
|
|
);
|
|
|
|
|
})
|
|
|
|
|
) : hasTree ? (
|
|
|
|
|
renderTree(root, 0)
|
|
|
|
|
) : (
|
|
|
|
|
<li className="px-2 py-1 typography-meta text-muted-foreground">Loading…</li>
|
|
|
|
|
)}
|
|
|
|
|
</ul>
|
|
|
|
|
</ScrollableOverlay>
|
|
|
|
|
</section>
|
|
|
|
|
);
|
|
|
|
|
|
2026-01-27 09:23:33 -08:00
|
|
|
// Fullscreen file viewer overlay
|
|
|
|
|
const fullscreenViewer = isFullscreen && selectedFile && (
|
|
|
|
|
<div className="absolute inset-0 z-50 flex flex-col bg-background">
|
|
|
|
|
{/* Fullscreen header */}
|
|
|
|
|
<div className="flex min-w-0 items-center gap-2 border-b border-border/40 px-4 py-2 flex-shrink-0">
|
|
|
|
|
<div className="min-w-0 flex-1">
|
|
|
|
|
<div className="typography-ui-label font-medium truncate">
|
|
|
|
|
{selectedFile.name}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="typography-meta text-muted-foreground truncate" title={displaySelectedPath}>
|
|
|
|
|
{displaySelectedPath}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="flex items-center gap-1">
|
|
|
|
|
{canEdit && (
|
|
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={() => void saveDraft()}
|
|
|
|
|
disabled={!isDirty || isSaving}
|
|
|
|
|
className="h-6 w-6 p-0 text-[color:var(--status-success)] opacity-70 hover:opacity-100"
|
|
|
|
|
title={`Save (${getModifierLabel()}+S)`}
|
|
|
|
|
aria-label={`Save (${getModifierLabel()}+S)`}
|
|
|
|
|
>
|
|
|
|
|
{isSaving ? (
|
|
|
|
|
<RiLoader4Line className="h-4 w-4 animate-spin" />
|
|
|
|
|
) : (
|
|
|
|
|
<RiSave3Line className="h-4 w-4" />
|
|
|
|
|
)}
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{canEdit && !isSelectedImage && (
|
|
|
|
|
<span aria-hidden="true" className="mx-1 h-4 w-px bg-border/60" />
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{!isSelectedImage && (
|
|
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={() => setWrapLines(!wrapLines)}
|
|
|
|
|
className={cn(
|
|
|
|
|
'h-6 w-6 p-0 transition-opacity',
|
|
|
|
|
wrapLines ? 'text-foreground opacity-100' : 'text-muted-foreground opacity-60 hover:opacity-100'
|
|
|
|
|
)}
|
|
|
|
|
title={wrapLines ? 'Disable line wrap' : 'Enable line wrap'}
|
|
|
|
|
>
|
|
|
|
|
<RiTextWrap className="size-4" />
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{(canCopy || canCopyPath || isMarkdownFile(selectedFile.path)) && (canEdit || !isSelectedImage) && (
|
|
|
|
|
<span aria-hidden="true" className="mx-1 h-4 w-px bg-border/60" />
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{isMarkdownFile(selectedFile.path) && (
|
|
|
|
|
<PreviewToggleButton
|
|
|
|
|
currentMode={getMdViewMode()}
|
|
|
|
|
onToggle={() => saveMdViewMode(getMdViewMode() === 'preview' ? 'edit' : 'preview')}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{canCopy && (
|
|
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={async () => {
|
|
|
|
|
try {
|
|
|
|
|
await navigator.clipboard.writeText(fileContent);
|
|
|
|
|
setCopiedContent(true);
|
|
|
|
|
if (copiedContentTimeoutRef.current !== null) {
|
|
|
|
|
window.clearTimeout(copiedContentTimeoutRef.current);
|
|
|
|
|
}
|
|
|
|
|
copiedContentTimeoutRef.current = window.setTimeout(() => {
|
|
|
|
|
setCopiedContent(false);
|
|
|
|
|
}, 1200);
|
|
|
|
|
} catch {
|
|
|
|
|
toast.error('Copy failed');
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
className="h-6 w-6 p-0"
|
|
|
|
|
title="Copy file contents"
|
|
|
|
|
aria-label="Copy file contents"
|
|
|
|
|
>
|
|
|
|
|
{copiedContent ? (
|
|
|
|
|
<RiCheckLine className="h-4 w-4 text-[color:var(--status-success)]" />
|
|
|
|
|
) : (
|
|
|
|
|
<RiClipboardLine className="h-4 w-4" />
|
|
|
|
|
)}
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{canCopyPath && (
|
|
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={async () => {
|
|
|
|
|
try {
|
|
|
|
|
await navigator.clipboard.writeText(displaySelectedPath);
|
|
|
|
|
setCopiedPath(true);
|
|
|
|
|
if (copiedPathTimeoutRef.current !== null) {
|
|
|
|
|
window.clearTimeout(copiedPathTimeoutRef.current);
|
|
|
|
|
}
|
|
|
|
|
copiedPathTimeoutRef.current = window.setTimeout(() => {
|
|
|
|
|
setCopiedPath(false);
|
|
|
|
|
}, 1200);
|
|
|
|
|
} catch {
|
|
|
|
|
toast.error('Copy failed');
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
className="h-6 w-6 p-0"
|
|
|
|
|
title={`Copy file path (${displaySelectedPath})`}
|
|
|
|
|
aria-label={`Copy file path (${displaySelectedPath})`}
|
|
|
|
|
>
|
|
|
|
|
{copiedPath ? (
|
|
|
|
|
<RiCheckLine className="h-4 w-4 text-[color:var(--status-success)]" />
|
|
|
|
|
) : (
|
|
|
|
|
<RiFileCopy2Line className="h-4 w-4" />
|
|
|
|
|
)}
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
<span aria-hidden="true" className="mx-1 h-4 w-px bg-border/60" />
|
|
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={() => setIsFullscreen(false)}
|
|
|
|
|
className="h-6 w-6 p-0"
|
|
|
|
|
title="Exit fullscreen"
|
|
|
|
|
aria-label="Exit fullscreen"
|
|
|
|
|
>
|
|
|
|
|
<RiFullscreenExitLine className="h-4 w-4" />
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Fullscreen content */}
|
|
|
|
|
<div className="flex-1 min-h-0 min-w-0 relative">
|
|
|
|
|
<ScrollableOverlay outerClassName="h-full min-w-0" className="h-full min-w-0">
|
|
|
|
|
{fileLoading ? (
|
|
|
|
|
<div className="p-4 flex items-center gap-2 typography-ui text-muted-foreground">
|
|
|
|
|
<RiLoader4Line className="h-4 w-4 animate-spin" />
|
|
|
|
|
Loading…
|
|
|
|
|
</div>
|
|
|
|
|
) : fileError ? (
|
|
|
|
|
<div className="p-4 typography-ui text-[color:var(--status-error)]">{fileError}</div>
|
|
|
|
|
) : isSelectedImage ? (
|
|
|
|
|
<div className="flex h-full items-center justify-center p-4">
|
|
|
|
|
<img
|
|
|
|
|
src={imageSrc}
|
|
|
|
|
alt={selectedFile.name}
|
|
|
|
|
className="max-w-full max-h-full object-contain rounded-md border border-border/30 bg-primary/10"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
) : isMarkdownFile(selectedFile.path) && getMdViewMode() === 'preview' ? (
|
|
|
|
|
<div className="h-full overflow-auto p-4">
|
|
|
|
|
{fileContent.length > 500 * 1024 && (
|
2026-02-01 18:29:34 +02:00
|
|
|
<div className="mb-3 rounded-md border border-status-warning/20 bg-status-warning/10 px-3 py-2 text-sm text-status-warning">
|
2026-01-27 09:23:33 -08:00
|
|
|
This file is large ({Math.round(fileContent.length / 1024)}KB). Preview may be limited.
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
<ErrorBoundary
|
|
|
|
|
fallback={
|
|
|
|
|
<div className="rounded-md border border-destructive/20 bg-destructive/10 px-3 py-2">
|
|
|
|
|
<div className="mb-1 font-medium text-destructive">Preview unavailable</div>
|
|
|
|
|
<div className="text-sm text-muted-foreground">
|
|
|
|
|
Switch to edit mode to fix the issue.
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
<SimpleMarkdownRenderer content={fileContent} className="typography-markdown-body" />
|
|
|
|
|
</ErrorBoundary>
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="h-full">
|
|
|
|
|
<CodeMirrorEditor
|
|
|
|
|
value={draftContent}
|
|
|
|
|
onChange={setDraftContent}
|
|
|
|
|
extensions={editorExtensions}
|
|
|
|
|
className="h-full"
|
2026-02-11 23:49:10 -08:00
|
|
|
onViewReady={(view) => {
|
|
|
|
|
editorViewRef.current = view;
|
|
|
|
|
window.requestAnimationFrame(() => {
|
|
|
|
|
nudgeEditorSelectionAboveKeyboard(view);
|
|
|
|
|
});
|
|
|
|
|
}}
|
|
|
|
|
onViewDestroy={() => {
|
|
|
|
|
if (editorViewRef.current) {
|
|
|
|
|
editorViewRef.current = null;
|
|
|
|
|
}
|
|
|
|
|
}}
|
2026-01-27 09:23:33 -08:00
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</ScrollableOverlay>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
|
2026-01-15 20:19:06 +02:00
|
|
|
return (
|
2026-01-27 09:23:33 -08:00
|
|
|
<div className="flex h-full min-h-0 overflow-hidden bg-background relative">
|
2026-01-22 12:21:19 +02:00
|
|
|
{renderDialogs()}
|
2026-01-27 09:23:33 -08:00
|
|
|
{fullscreenViewer}
|
2026-01-15 20:19:06 +02:00
|
|
|
{isMobile ? (
|
|
|
|
|
showMobilePageContent ? (
|
|
|
|
|
fileViewer
|
|
|
|
|
) : (
|
|
|
|
|
treePanel
|
|
|
|
|
)
|
2026-01-18 17:29:22 +02:00
|
|
|
) : (
|
|
|
|
|
<div className="flex flex-1 min-h-0 min-w-0 gap-3 px-3 pb-3 pt-2">
|
2026-02-01 18:29:34 +02:00
|
|
|
{screenWidth >= 700 && (
|
2026-01-18 17:29:22 +02:00
|
|
|
<div className="w-72 flex-shrink-0 min-h-0 overflow-hidden">
|
|
|
|
|
{treePanel}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
<div className="flex-1 min-h-0 min-w-0 overflow-hidden rounded-xl border border-border/60 bg-background">
|
|
|
|
|
{fileViewer}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2026-01-15 20:19:06 +02:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|