mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-03-17 10:03:08 +00:00
Feature: File Editor (#789)
* feat: Add file management feature * feat: Add auto-save functionality to file editor * fix: Replace HardDriveDownload icon with Save icon for consistency * fix: Prevent recursive copy/move and improve shell injection prevention * refactor: Extract editor settings form into separate component
This commit is contained in:
@@ -2,6 +2,7 @@ import { useMemo, useState, useEffect } from 'react';
|
||||
import type { NavigateOptions } from '@tanstack/react-router';
|
||||
import {
|
||||
FileText,
|
||||
Folder,
|
||||
LayoutGrid,
|
||||
Bot,
|
||||
BookOpen,
|
||||
@@ -142,7 +143,7 @@ export function useNavigation({
|
||||
return true;
|
||||
});
|
||||
|
||||
// Build project items - Terminal is conditionally included
|
||||
// Build project items - Terminal and File Editor are conditionally included
|
||||
const projectItems: NavItem[] = [
|
||||
{
|
||||
id: 'board',
|
||||
@@ -156,6 +157,11 @@ export function useNavigation({
|
||||
icon: Network,
|
||||
shortcut: shortcuts.graph,
|
||||
},
|
||||
{
|
||||
id: 'file-editor',
|
||||
label: 'File Editor',
|
||||
icon: Folder,
|
||||
},
|
||||
{
|
||||
id: 'agent',
|
||||
label: 'Agent Runner',
|
||||
|
||||
@@ -0,0 +1,603 @@
|
||||
import { useMemo, useEffect, useRef, forwardRef, useImperativeHandle } from 'react';
|
||||
import CodeMirror, { type ReactCodeMirrorRef } from '@uiw/react-codemirror';
|
||||
import { EditorView, keymap } from '@codemirror/view';
|
||||
import { Extension } from '@codemirror/state';
|
||||
import { undo as cmUndo, redo as cmRedo } from '@codemirror/commands';
|
||||
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language';
|
||||
import { tags as t } from '@lezer/highlight';
|
||||
import { search, openSearchPanel } from '@codemirror/search';
|
||||
|
||||
// Language imports
|
||||
import { javascript } from '@codemirror/lang-javascript';
|
||||
import { html } from '@codemirror/lang-html';
|
||||
import { css } from '@codemirror/lang-css';
|
||||
import { json } from '@codemirror/lang-json';
|
||||
import { markdown } from '@codemirror/lang-markdown';
|
||||
import { python } from '@codemirror/lang-python';
|
||||
import { java } from '@codemirror/lang-java';
|
||||
import { rust } from '@codemirror/lang-rust';
|
||||
import { cpp } from '@codemirror/lang-cpp';
|
||||
import { sql } from '@codemirror/lang-sql';
|
||||
import { php } from '@codemirror/lang-php';
|
||||
import { xml } from '@codemirror/lang-xml';
|
||||
import { StreamLanguage } from '@codemirror/language';
|
||||
import { shell } from '@codemirror/legacy-modes/mode/shell';
|
||||
import { yaml } from '@codemirror/legacy-modes/mode/yaml';
|
||||
import { toml } from '@codemirror/legacy-modes/mode/toml';
|
||||
import { dockerFile } from '@codemirror/legacy-modes/mode/dockerfile';
|
||||
import { go } from '@codemirror/legacy-modes/mode/go';
|
||||
import { ruby } from '@codemirror/legacy-modes/mode/ruby';
|
||||
import { swift } from '@codemirror/legacy-modes/mode/swift';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useIsMobile } from '@/hooks/use-media-query';
|
||||
import { DEFAULT_FONT_VALUE } from '@/config/ui-font-options';
|
||||
|
||||
/** Default monospace font stack used when no custom font is set */
|
||||
const DEFAULT_EDITOR_FONT =
|
||||
'var(--font-mono, ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace)';
|
||||
|
||||
/** Get the actual CSS font family value for the editor */
|
||||
function getEditorFontFamily(fontValue: string | undefined): string {
|
||||
if (!fontValue || fontValue === DEFAULT_FONT_VALUE) {
|
||||
return DEFAULT_EDITOR_FONT;
|
||||
}
|
||||
return fontValue;
|
||||
}
|
||||
|
||||
/** Handle exposed by CodeEditor for external control */
|
||||
export interface CodeEditorHandle {
|
||||
/** Opens the CodeMirror search panel */
|
||||
openSearch: () => void;
|
||||
/** Focuses the editor */
|
||||
focus: () => void;
|
||||
/** Undoes the last edit */
|
||||
undo: () => void;
|
||||
/** Redoes the last undone edit */
|
||||
redo: () => void;
|
||||
}
|
||||
|
||||
interface CodeEditorProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
filePath: string;
|
||||
readOnly?: boolean;
|
||||
tabSize?: number;
|
||||
wordWrap?: boolean;
|
||||
fontSize?: number;
|
||||
/** CSS font-family value for the editor. Use 'default' or undefined for the theme default mono font. */
|
||||
fontFamily?: string;
|
||||
onCursorChange?: (line: number, col: number) => void;
|
||||
onSave?: () => void;
|
||||
className?: string;
|
||||
/** When true, scrolls the cursor into view (e.g. after virtual keyboard opens) */
|
||||
scrollCursorIntoView?: boolean;
|
||||
}
|
||||
|
||||
/** Detect language extension based on file extension */
|
||||
function getLanguageExtension(filePath: string): Extension | null {
|
||||
const name = filePath.split('/').pop()?.toLowerCase() || '';
|
||||
const dotIndex = name.lastIndexOf('.');
|
||||
// Files without an extension (no dot, or dotfile with dot at position 0)
|
||||
const ext = dotIndex > 0 ? name.slice(dotIndex + 1) : '';
|
||||
|
||||
// Handle files by name first
|
||||
switch (name) {
|
||||
case 'dockerfile':
|
||||
case 'dockerfile.dev':
|
||||
case 'dockerfile.prod':
|
||||
return StreamLanguage.define(dockerFile);
|
||||
case 'makefile':
|
||||
case 'gnumakefile':
|
||||
return StreamLanguage.define(shell);
|
||||
case '.gitignore':
|
||||
case '.dockerignore':
|
||||
case '.npmignore':
|
||||
case '.eslintignore':
|
||||
return StreamLanguage.define(shell); // close enough for ignore files
|
||||
case '.env':
|
||||
case '.env.local':
|
||||
case '.env.development':
|
||||
case '.env.production':
|
||||
return StreamLanguage.define(shell);
|
||||
}
|
||||
|
||||
switch (ext) {
|
||||
// JavaScript/TypeScript
|
||||
case 'js':
|
||||
case 'mjs':
|
||||
case 'cjs':
|
||||
return javascript();
|
||||
case 'jsx':
|
||||
return javascript({ jsx: true });
|
||||
case 'ts':
|
||||
case 'mts':
|
||||
case 'cts':
|
||||
return javascript({ typescript: true });
|
||||
case 'tsx':
|
||||
return javascript({ jsx: true, typescript: true });
|
||||
|
||||
// Web
|
||||
case 'html':
|
||||
case 'htm':
|
||||
case 'svelte':
|
||||
case 'vue':
|
||||
return html();
|
||||
case 'css':
|
||||
case 'scss':
|
||||
case 'less':
|
||||
return css();
|
||||
case 'json':
|
||||
case 'jsonc':
|
||||
case 'json5':
|
||||
return json();
|
||||
case 'xml':
|
||||
case 'svg':
|
||||
case 'xsl':
|
||||
case 'xslt':
|
||||
case 'plist':
|
||||
return xml();
|
||||
|
||||
// Markdown
|
||||
case 'md':
|
||||
case 'mdx':
|
||||
case 'markdown':
|
||||
return markdown();
|
||||
|
||||
// Python
|
||||
case 'py':
|
||||
case 'pyx':
|
||||
case 'pyi':
|
||||
return python();
|
||||
|
||||
// Java/Kotlin
|
||||
case 'java':
|
||||
case 'kt':
|
||||
case 'kts':
|
||||
return java();
|
||||
|
||||
// Systems
|
||||
case 'rs':
|
||||
return rust();
|
||||
case 'c':
|
||||
case 'h':
|
||||
return cpp();
|
||||
case 'cpp':
|
||||
case 'cc':
|
||||
case 'cxx':
|
||||
case 'hpp':
|
||||
case 'hxx':
|
||||
return cpp();
|
||||
case 'go':
|
||||
return StreamLanguage.define(go);
|
||||
case 'swift':
|
||||
return StreamLanguage.define(swift);
|
||||
|
||||
// Scripting
|
||||
case 'rb':
|
||||
case 'erb':
|
||||
return StreamLanguage.define(ruby);
|
||||
case 'php':
|
||||
return php();
|
||||
case 'sh':
|
||||
case 'bash':
|
||||
case 'zsh':
|
||||
case 'fish':
|
||||
return StreamLanguage.define(shell);
|
||||
|
||||
// Data
|
||||
case 'sql':
|
||||
case 'mysql':
|
||||
case 'pgsql':
|
||||
return sql();
|
||||
case 'yaml':
|
||||
case 'yml':
|
||||
return StreamLanguage.define(yaml);
|
||||
case 'toml':
|
||||
return StreamLanguage.define(toml);
|
||||
|
||||
default:
|
||||
return null; // Plain text fallback
|
||||
}
|
||||
}
|
||||
|
||||
/** Get a human-readable language name */
|
||||
export function getLanguageName(filePath: string): string {
|
||||
const name = filePath.split('/').pop()?.toLowerCase() || '';
|
||||
const dotIndex = name.lastIndexOf('.');
|
||||
// Files without an extension (no dot, or dotfile with dot at position 0)
|
||||
const ext = dotIndex > 0 ? name.slice(dotIndex + 1) : '';
|
||||
|
||||
if (name === 'dockerfile' || name.startsWith('dockerfile.')) return 'Dockerfile';
|
||||
if (name === 'makefile' || name === 'gnumakefile') return 'Makefile';
|
||||
if (name.startsWith('.env')) return 'Environment';
|
||||
if (name.startsWith('.git') || name.startsWith('.npm') || name.startsWith('.docker'))
|
||||
return 'Config';
|
||||
|
||||
switch (ext) {
|
||||
case 'js':
|
||||
case 'mjs':
|
||||
case 'cjs':
|
||||
return 'JavaScript';
|
||||
case 'jsx':
|
||||
return 'JSX';
|
||||
case 'ts':
|
||||
case 'mts':
|
||||
case 'cts':
|
||||
return 'TypeScript';
|
||||
case 'tsx':
|
||||
return 'TSX';
|
||||
case 'html':
|
||||
case 'htm':
|
||||
return 'HTML';
|
||||
case 'svelte':
|
||||
return 'Svelte';
|
||||
case 'vue':
|
||||
return 'Vue';
|
||||
case 'css':
|
||||
return 'CSS';
|
||||
case 'scss':
|
||||
return 'SCSS';
|
||||
case 'less':
|
||||
return 'Less';
|
||||
case 'json':
|
||||
case 'jsonc':
|
||||
case 'json5':
|
||||
return 'JSON';
|
||||
case 'xml':
|
||||
case 'svg':
|
||||
return 'XML';
|
||||
case 'md':
|
||||
case 'mdx':
|
||||
case 'markdown':
|
||||
return 'Markdown';
|
||||
case 'py':
|
||||
case 'pyx':
|
||||
case 'pyi':
|
||||
return 'Python';
|
||||
case 'java':
|
||||
return 'Java';
|
||||
case 'kt':
|
||||
case 'kts':
|
||||
return 'Kotlin';
|
||||
case 'rs':
|
||||
return 'Rust';
|
||||
case 'c':
|
||||
case 'h':
|
||||
return 'C';
|
||||
case 'cpp':
|
||||
case 'cc':
|
||||
case 'cxx':
|
||||
case 'hpp':
|
||||
return 'C++';
|
||||
case 'go':
|
||||
return 'Go';
|
||||
case 'swift':
|
||||
return 'Swift';
|
||||
case 'rb':
|
||||
case 'erb':
|
||||
return 'Ruby';
|
||||
case 'php':
|
||||
return 'PHP';
|
||||
case 'sh':
|
||||
case 'bash':
|
||||
case 'zsh':
|
||||
return 'Shell';
|
||||
case 'sql':
|
||||
return 'SQL';
|
||||
case 'yaml':
|
||||
case 'yml':
|
||||
return 'YAML';
|
||||
case 'toml':
|
||||
return 'TOML';
|
||||
default:
|
||||
return 'Plain Text';
|
||||
}
|
||||
}
|
||||
|
||||
// Syntax highlighting using CSS variables for theme compatibility
|
||||
const syntaxColors = HighlightStyle.define([
|
||||
{ tag: t.keyword, color: 'var(--chart-4, oklch(0.7 0.15 280))' },
|
||||
{ tag: t.string, color: 'var(--chart-1, oklch(0.646 0.222 41.116))' },
|
||||
{ tag: t.number, color: 'var(--chart-3, oklch(0.7 0.15 150))' },
|
||||
{ tag: t.bool, color: 'var(--chart-4, oklch(0.7 0.15 280))' },
|
||||
{ tag: t.null, color: 'var(--chart-4, oklch(0.7 0.15 280))' },
|
||||
{ tag: t.comment, color: 'var(--muted-foreground)', fontStyle: 'italic' },
|
||||
{ tag: t.propertyName, color: 'var(--chart-2, oklch(0.6 0.118 184.704))' },
|
||||
{ tag: t.variableName, color: 'var(--chart-2, oklch(0.6 0.118 184.704))' },
|
||||
{ tag: t.function(t.variableName), color: 'var(--primary)' },
|
||||
{ tag: t.typeName, color: 'var(--chart-5, oklch(0.65 0.2 30))' },
|
||||
{ tag: t.className, color: 'var(--chart-5, oklch(0.65 0.2 30))' },
|
||||
{ tag: t.definition(t.variableName), color: 'var(--chart-2, oklch(0.6 0.118 184.704))' },
|
||||
{ tag: t.operator, color: 'var(--muted-foreground)' },
|
||||
{ tag: t.bracket, color: 'var(--muted-foreground)' },
|
||||
{ tag: t.punctuation, color: 'var(--muted-foreground)' },
|
||||
{ tag: t.attributeName, color: 'var(--chart-5, oklch(0.65 0.2 30))' },
|
||||
{ tag: t.attributeValue, color: 'var(--chart-1, oklch(0.646 0.222 41.116))' },
|
||||
{ tag: t.tagName, color: 'var(--chart-4, oklch(0.7 0.15 280))' },
|
||||
{ tag: t.heading, color: 'var(--foreground)', fontWeight: 'bold' },
|
||||
{ tag: t.emphasis, fontStyle: 'italic' },
|
||||
{ tag: t.strong, fontWeight: 'bold' },
|
||||
{ tag: t.link, color: 'var(--primary)', textDecoration: 'underline' },
|
||||
{ tag: t.content, color: 'var(--foreground)' },
|
||||
{ tag: t.regexp, color: 'var(--chart-1, oklch(0.646 0.222 41.116))' },
|
||||
{ tag: t.meta, color: 'var(--muted-foreground)' },
|
||||
]);
|
||||
|
||||
export const CodeEditor = forwardRef<CodeEditorHandle, CodeEditorProps>(function CodeEditor(
|
||||
{
|
||||
value,
|
||||
onChange,
|
||||
filePath,
|
||||
readOnly = false,
|
||||
tabSize = 2,
|
||||
wordWrap = true,
|
||||
fontSize = 13,
|
||||
fontFamily,
|
||||
onCursorChange,
|
||||
onSave,
|
||||
className,
|
||||
scrollCursorIntoView = false,
|
||||
},
|
||||
ref
|
||||
) {
|
||||
const editorRef = useRef<ReactCodeMirrorRef>(null);
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
// Stable refs for callbacks to avoid frequent extension rebuilds
|
||||
const onSaveRef = useRef(onSave);
|
||||
const onCursorChangeRef = useRef(onCursorChange);
|
||||
useEffect(() => {
|
||||
onSaveRef.current = onSave;
|
||||
}, [onSave]);
|
||||
useEffect(() => {
|
||||
onCursorChangeRef.current = onCursorChange;
|
||||
}, [onCursorChange]);
|
||||
|
||||
// Expose imperative methods to parent components
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
openSearch: () => {
|
||||
if (editorRef.current?.view) {
|
||||
editorRef.current.view.focus();
|
||||
openSearchPanel(editorRef.current.view);
|
||||
}
|
||||
},
|
||||
focus: () => {
|
||||
if (editorRef.current?.view) {
|
||||
editorRef.current.view.focus();
|
||||
}
|
||||
},
|
||||
undo: () => {
|
||||
if (editorRef.current?.view) {
|
||||
editorRef.current.view.focus();
|
||||
cmUndo(editorRef.current.view);
|
||||
}
|
||||
},
|
||||
redo: () => {
|
||||
if (editorRef.current?.view) {
|
||||
editorRef.current.view.focus();
|
||||
cmRedo(editorRef.current.view);
|
||||
}
|
||||
},
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
// When the virtual keyboard opens on mobile, the container shrinks but the
|
||||
// cursor may be below the new fold. Dispatch a scrollIntoView effect so
|
||||
// CodeMirror re-centres the viewport around the caret.
|
||||
useEffect(() => {
|
||||
if (scrollCursorIntoView && editorRef.current?.view) {
|
||||
const view = editorRef.current.view;
|
||||
// Request CodeMirror to scroll the current selection into view
|
||||
view.dispatch({
|
||||
effects: EditorView.scrollIntoView(view.state.selection.main.head, { y: 'center' }),
|
||||
});
|
||||
}
|
||||
}, [scrollCursorIntoView]);
|
||||
|
||||
// Resolve the effective font family CSS value
|
||||
const resolvedFontFamily = useMemo(() => getEditorFontFamily(fontFamily), [fontFamily]);
|
||||
|
||||
// Build editor theme dynamically based on fontSize, fontFamily, and screen size
|
||||
const editorTheme = useMemo(
|
||||
() =>
|
||||
EditorView.theme({
|
||||
'&': {
|
||||
height: '100%',
|
||||
fontSize: `${fontSize}px`,
|
||||
fontFamily: resolvedFontFamily,
|
||||
backgroundColor: 'transparent',
|
||||
color: 'var(--foreground)',
|
||||
},
|
||||
'.cm-scroller': {
|
||||
overflow: 'auto',
|
||||
fontFamily: resolvedFontFamily,
|
||||
},
|
||||
'.cm-content': {
|
||||
padding: '0.5rem 0',
|
||||
minHeight: '100%',
|
||||
caretColor: 'var(--primary)',
|
||||
},
|
||||
'.cm-cursor, .cm-dropCursor': {
|
||||
borderLeftColor: 'var(--primary)',
|
||||
},
|
||||
'&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection': {
|
||||
backgroundColor: 'oklch(0.55 0.25 265 / 0.3)',
|
||||
},
|
||||
'.cm-activeLine': {
|
||||
backgroundColor: 'var(--accent)',
|
||||
opacity: '0.3',
|
||||
},
|
||||
'.cm-line': {
|
||||
padding: '0 0.5rem',
|
||||
},
|
||||
'&.cm-focused': {
|
||||
outline: 'none',
|
||||
},
|
||||
'.cm-gutters': {
|
||||
backgroundColor: 'transparent',
|
||||
color: 'var(--muted-foreground)',
|
||||
border: 'none',
|
||||
borderRight: '1px solid var(--border)',
|
||||
paddingRight: '0.25rem',
|
||||
},
|
||||
'.cm-lineNumbers .cm-gutterElement': {
|
||||
minWidth: isMobile ? '1.75rem' : '3rem',
|
||||
textAlign: 'right',
|
||||
paddingRight: isMobile ? '0.25rem' : '0.5rem',
|
||||
fontSize: `${fontSize - 1}px`,
|
||||
},
|
||||
'.cm-foldGutter .cm-gutterElement': {
|
||||
padding: '0 0.25rem',
|
||||
},
|
||||
'.cm-placeholder': {
|
||||
color: 'var(--muted-foreground)',
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
// Search panel styling
|
||||
'.cm-panels': {
|
||||
backgroundColor: 'var(--card)',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
},
|
||||
'.cm-panels-top': {
|
||||
borderBottom: '1px solid var(--border)',
|
||||
},
|
||||
'.cm-search': {
|
||||
backgroundColor: 'var(--card)',
|
||||
padding: '0.5rem 0.75rem',
|
||||
gap: '0.375rem',
|
||||
fontSize: `${fontSize - 1}px`,
|
||||
},
|
||||
'.cm-search input, .cm-search select': {
|
||||
backgroundColor: 'var(--background)',
|
||||
color: 'var(--foreground)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: '0.375rem',
|
||||
padding: '0.25rem 0.5rem',
|
||||
outline: 'none',
|
||||
fontSize: `${fontSize - 1}px`,
|
||||
fontFamily:
|
||||
'var(--font-mono, ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace)',
|
||||
},
|
||||
'.cm-search input:focus': {
|
||||
borderColor: 'var(--primary)',
|
||||
boxShadow: '0 0 0 1px var(--primary)',
|
||||
},
|
||||
'.cm-search button': {
|
||||
backgroundColor: 'var(--muted)',
|
||||
color: 'var(--foreground)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: '0.375rem',
|
||||
padding: '0.25rem 0.625rem',
|
||||
cursor: 'pointer',
|
||||
fontSize: `${fontSize - 1}px`,
|
||||
transition: 'background-color 0.15s ease',
|
||||
},
|
||||
'.cm-search button:hover': {
|
||||
backgroundColor: 'var(--accent)',
|
||||
},
|
||||
'.cm-search button[name="close"]': {
|
||||
backgroundColor: 'transparent',
|
||||
border: 'none',
|
||||
padding: '0.25rem',
|
||||
borderRadius: '0.25rem',
|
||||
color: 'var(--muted-foreground)',
|
||||
},
|
||||
'.cm-search button[name="close"]:hover': {
|
||||
backgroundColor: 'var(--accent)',
|
||||
color: 'var(--foreground)',
|
||||
},
|
||||
'.cm-search label': {
|
||||
color: 'var(--muted-foreground)',
|
||||
fontSize: `${fontSize - 1}px`,
|
||||
},
|
||||
'.cm-search .cm-textfield': {
|
||||
minWidth: '10rem',
|
||||
},
|
||||
'.cm-searchMatch': {
|
||||
backgroundColor: 'oklch(0.7 0.2 90 / 0.3)',
|
||||
borderRadius: '1px',
|
||||
},
|
||||
'.cm-searchMatch-selected': {
|
||||
backgroundColor: 'oklch(0.6 0.25 265 / 0.4)',
|
||||
},
|
||||
}),
|
||||
[fontSize, resolvedFontFamily, isMobile]
|
||||
);
|
||||
|
||||
// Build extensions list
|
||||
// Uses refs for onSave/onCursorChange to avoid frequent extension rebuilds
|
||||
// when parent passes inline arrow functions
|
||||
const extensions = useMemo(() => {
|
||||
const exts: Extension[] = [
|
||||
syntaxHighlighting(syntaxColors),
|
||||
editorTheme,
|
||||
search(),
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (update.selectionSet && onCursorChangeRef.current) {
|
||||
const pos = update.state.selection.main.head;
|
||||
const line = update.state.doc.lineAt(pos);
|
||||
onCursorChangeRef.current(line.number, pos - line.from + 1);
|
||||
}
|
||||
}),
|
||||
];
|
||||
|
||||
// Add save keybinding (always register, check ref at call time)
|
||||
exts.push(
|
||||
keymap.of([
|
||||
{
|
||||
key: 'Mod-s',
|
||||
run: () => {
|
||||
onSaveRef.current?.();
|
||||
return true;
|
||||
},
|
||||
},
|
||||
])
|
||||
);
|
||||
|
||||
// Add word wrap
|
||||
if (wordWrap) {
|
||||
exts.push(EditorView.lineWrapping);
|
||||
}
|
||||
|
||||
// Add tab size
|
||||
exts.push(EditorView.editorAttributes.of({ style: `tab-size: ${tabSize}` }));
|
||||
|
||||
// Add language extension
|
||||
const langExt = getLanguageExtension(filePath);
|
||||
if (langExt) {
|
||||
exts.push(langExt);
|
||||
}
|
||||
|
||||
return exts;
|
||||
}, [filePath, wordWrap, tabSize, editorTheme]);
|
||||
|
||||
return (
|
||||
<div className={cn('h-full w-full', className)}>
|
||||
<CodeMirror
|
||||
ref={editorRef}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
extensions={extensions}
|
||||
theme="none"
|
||||
height="100%"
|
||||
readOnly={readOnly}
|
||||
className="h-full [&_.cm-editor]:h-full"
|
||||
basicSetup={{
|
||||
lineNumbers: true,
|
||||
foldGutter: true,
|
||||
highlightActiveLine: true,
|
||||
highlightSelectionMatches: true,
|
||||
autocompletion: false,
|
||||
bracketMatching: true,
|
||||
indentOnInput: true,
|
||||
closeBrackets: true,
|
||||
tabSize,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import { RotateCcw } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { UI_MONO_FONT_OPTIONS, DEFAULT_FONT_VALUE } from '@/config/ui-font-options';
|
||||
|
||||
interface EditorSettingsFormProps {
|
||||
editorFontSize: number;
|
||||
setEditorFontSize: (value: number) => void;
|
||||
editorFontFamily: string | null | undefined;
|
||||
setEditorFontFamily: (value: string) => void;
|
||||
editorAutoSave: boolean;
|
||||
setEditorAutoSave: (value: boolean) => void;
|
||||
}
|
||||
|
||||
export function EditorSettingsForm({
|
||||
editorFontSize,
|
||||
setEditorFontSize,
|
||||
editorFontFamily,
|
||||
setEditorFontFamily,
|
||||
editorAutoSave,
|
||||
setEditorAutoSave,
|
||||
}: EditorSettingsFormProps) {
|
||||
return (
|
||||
<>
|
||||
{/* Font Size */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs font-medium">Font Size</Label>
|
||||
<span className="text-xs text-muted-foreground">{editorFontSize}px</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Slider
|
||||
value={[editorFontSize]}
|
||||
min={8}
|
||||
max={32}
|
||||
step={1}
|
||||
onValueChange={([value]) => setEditorFontSize(value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 shrink-0"
|
||||
onClick={() => setEditorFontSize(13)}
|
||||
disabled={editorFontSize === 13}
|
||||
title="Reset to default"
|
||||
>
|
||||
<RotateCcw className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Font Family */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium">Font Family</Label>
|
||||
<Select
|
||||
value={editorFontFamily || DEFAULT_FONT_VALUE}
|
||||
onValueChange={(value) => setEditorFontFamily(value)}
|
||||
>
|
||||
<SelectTrigger className="w-full h-8 text-xs">
|
||||
<SelectValue placeholder="Default (Geist Mono)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{UI_MONO_FONT_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: option.value === DEFAULT_FONT_VALUE ? undefined : option.value,
|
||||
}}
|
||||
>
|
||||
{option.label}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Auto Save toggle */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs font-medium">Auto Save</Label>
|
||||
<Switch checked={editorAutoSave} onCheckedChange={setEditorAutoSave} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { X, Circle, MoreHorizontal } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { EditorTab } from '../use-file-editor-store';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
|
||||
interface EditorTabsProps {
|
||||
tabs: EditorTab[];
|
||||
activeTabId: string | null;
|
||||
onTabSelect: (tabId: string) => void;
|
||||
onTabClose: (tabId: string) => void;
|
||||
onCloseAll: () => void;
|
||||
}
|
||||
|
||||
/** Get a file icon color based on extension */
|
||||
function getFileColor(fileName: string): string {
|
||||
const dotIndex = fileName.lastIndexOf('.');
|
||||
// Files without an extension (no dot, or dotfile with dot at position 0)
|
||||
const ext = dotIndex > 0 ? fileName.slice(dotIndex + 1).toLowerCase() : '';
|
||||
switch (ext) {
|
||||
case 'ts':
|
||||
case 'tsx':
|
||||
return 'text-blue-400';
|
||||
case 'js':
|
||||
case 'jsx':
|
||||
case 'mjs':
|
||||
return 'text-yellow-400';
|
||||
case 'css':
|
||||
case 'scss':
|
||||
case 'less':
|
||||
return 'text-purple-400';
|
||||
case 'html':
|
||||
case 'htm':
|
||||
return 'text-orange-400';
|
||||
case 'json':
|
||||
return 'text-yellow-300';
|
||||
case 'md':
|
||||
case 'mdx':
|
||||
return 'text-gray-300';
|
||||
case 'py':
|
||||
return 'text-green-400';
|
||||
case 'rs':
|
||||
return 'text-orange-500';
|
||||
case 'go':
|
||||
return 'text-cyan-400';
|
||||
case 'rb':
|
||||
return 'text-red-400';
|
||||
case 'java':
|
||||
case 'kt':
|
||||
return 'text-red-500';
|
||||
case 'sql':
|
||||
return 'text-blue-300';
|
||||
case 'yaml':
|
||||
case 'yml':
|
||||
return 'text-pink-400';
|
||||
case 'toml':
|
||||
return 'text-gray-400';
|
||||
case 'sh':
|
||||
case 'bash':
|
||||
case 'zsh':
|
||||
return 'text-green-300';
|
||||
default:
|
||||
return 'text-muted-foreground';
|
||||
}
|
||||
}
|
||||
|
||||
export function EditorTabs({
|
||||
tabs,
|
||||
activeTabId,
|
||||
onTabSelect,
|
||||
onTabClose,
|
||||
onCloseAll,
|
||||
}: EditorTabsProps) {
|
||||
if (tabs.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center border-b border-border bg-muted/30 overflow-x-auto"
|
||||
data-testid="editor-tabs"
|
||||
>
|
||||
{tabs.map((tab) => {
|
||||
const isActive = tab.id === activeTabId;
|
||||
const fileColor = getFileColor(tab.fileName);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={tab.id}
|
||||
className={cn(
|
||||
'group flex items-center gap-1.5 px-3 py-1.5 cursor-pointer border-r border-border min-w-0 max-w-[200px] text-sm transition-colors',
|
||||
isActive
|
||||
? 'bg-background text-foreground border-b-2 border-b-primary'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50'
|
||||
)}
|
||||
onClick={() => onTabSelect(tab.id)}
|
||||
title={tab.filePath}
|
||||
>
|
||||
{/* Dirty indicator */}
|
||||
{tab.isDirty ? (
|
||||
<Circle className="w-2 h-2 shrink-0 fill-current text-primary" />
|
||||
) : (
|
||||
<span className={cn('w-2 h-2 rounded-full shrink-0', fileColor)} />
|
||||
)}
|
||||
|
||||
{/* File name */}
|
||||
<span className="truncate">{tab.fileName}</span>
|
||||
|
||||
{/* Close button */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onTabClose(tab.id);
|
||||
}}
|
||||
className={cn(
|
||||
'p-0.5 rounded shrink-0 transition-colors',
|
||||
'opacity-0 group-hover:opacity-100',
|
||||
isActive && 'opacity-60',
|
||||
'hover:bg-accent'
|
||||
)}
|
||||
title="Close"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Tab actions dropdown (close all, etc.) */}
|
||||
<div className="ml-auto shrink-0 flex items-center px-1">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
className="p-1 rounded text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors"
|
||||
title="Tab actions"
|
||||
>
|
||||
<MoreHorizontal className="w-4 h-4" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-40">
|
||||
<DropdownMenuItem onClick={onCloseAll} className="gap-2 cursor-pointer">
|
||||
<X className="w-4 h-4" />
|
||||
<span>Close All</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,927 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
File,
|
||||
Folder,
|
||||
FolderOpen,
|
||||
FolderPlus,
|
||||
FilePlus,
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
Trash2,
|
||||
Pencil,
|
||||
Eye,
|
||||
EyeOff,
|
||||
RefreshCw,
|
||||
MoreVertical,
|
||||
Copy,
|
||||
ClipboardCopy,
|
||||
FolderInput,
|
||||
FolderOutput,
|
||||
Download,
|
||||
Plus,
|
||||
Minus,
|
||||
AlertTriangle,
|
||||
GripVertical,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { useFileEditorStore, type FileTreeNode } from '../use-file-editor-store';
|
||||
|
||||
interface FileTreeProps {
|
||||
onFileSelect: (path: string) => void;
|
||||
onCreateFile: (parentPath: string, name: string) => Promise<void>;
|
||||
onCreateFolder: (parentPath: string, name: string) => Promise<void>;
|
||||
onDeleteItem: (path: string, isDirectory: boolean) => Promise<void>;
|
||||
onRenameItem: (oldPath: string, newName: string) => Promise<void>;
|
||||
onCopyPath: (path: string) => void;
|
||||
onRefresh: () => void;
|
||||
onToggleFolder: (path: string) => void;
|
||||
activeFilePath: string | null;
|
||||
onCopyItem?: (sourcePath: string, destinationPath: string) => Promise<void>;
|
||||
onMoveItem?: (sourcePath: string, destinationPath: string) => Promise<void>;
|
||||
onDownloadItem?: (filePath: string) => Promise<void>;
|
||||
onDragDropMove?: (sourcePaths: string[], targetFolderPath: string) => Promise<void>;
|
||||
effectivePath?: string;
|
||||
}
|
||||
|
||||
/** Get a color class for git status */
|
||||
function getGitStatusColor(status: string | undefined): string {
|
||||
if (!status) return '';
|
||||
switch (status) {
|
||||
case 'M':
|
||||
return 'text-yellow-500'; // modified
|
||||
case 'A':
|
||||
return 'text-green-500'; // added/staged
|
||||
case 'D':
|
||||
return 'text-red-500'; // deleted
|
||||
case '?':
|
||||
return 'text-gray-400'; // untracked
|
||||
case '!':
|
||||
return 'text-gray-600'; // ignored
|
||||
case 'S':
|
||||
return 'text-blue-500'; // staged
|
||||
case 'R':
|
||||
return 'text-purple-500'; // renamed
|
||||
case 'C':
|
||||
return 'text-cyan-500'; // copied
|
||||
case 'U':
|
||||
return 'text-orange-500'; // conflicted
|
||||
default:
|
||||
return 'text-muted-foreground';
|
||||
}
|
||||
}
|
||||
|
||||
/** Get a status label for git status */
|
||||
function getGitStatusLabel(status: string | undefined): string {
|
||||
if (!status) return '';
|
||||
switch (status) {
|
||||
case 'M':
|
||||
return 'Modified';
|
||||
case 'A':
|
||||
return 'Added';
|
||||
case 'D':
|
||||
return 'Deleted';
|
||||
case '?':
|
||||
return 'Untracked';
|
||||
case '!':
|
||||
return 'Ignored';
|
||||
case 'S':
|
||||
return 'Staged';
|
||||
case 'R':
|
||||
return 'Renamed';
|
||||
case 'C':
|
||||
return 'Copied';
|
||||
case 'U':
|
||||
return 'Conflicted';
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
/** Inline input for creating/renaming items */
|
||||
function InlineInput({
|
||||
defaultValue,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
placeholder,
|
||||
}: {
|
||||
defaultValue?: string;
|
||||
onSubmit: (value: string) => void;
|
||||
onCancel: () => void;
|
||||
placeholder?: string;
|
||||
}) {
|
||||
const [value, setValue] = useState(defaultValue || '');
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
// Guard against double-submission: pressing Enter triggers onKeyDown AND may
|
||||
// immediately trigger onBlur (e.g. when the component unmounts after submit).
|
||||
const submittedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
if (defaultValue) {
|
||||
// Select name without extension for rename
|
||||
const dotIndex = defaultValue.lastIndexOf('.');
|
||||
if (dotIndex > 0) {
|
||||
inputRef.current?.setSelectionRange(0, dotIndex);
|
||||
} else {
|
||||
inputRef.current?.select();
|
||||
}
|
||||
}
|
||||
}, [defaultValue]);
|
||||
|
||||
return (
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && value.trim()) {
|
||||
if (submittedRef.current) return;
|
||||
submittedRef.current = true;
|
||||
onSubmit(value.trim());
|
||||
} else if (e.key === 'Escape') {
|
||||
onCancel();
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
// Prevent duplicate submission if onKeyDown already triggered onSubmit
|
||||
if (submittedRef.current) return;
|
||||
if (value.trim()) {
|
||||
submittedRef.current = true;
|
||||
onSubmit(value.trim());
|
||||
} else {
|
||||
onCancel();
|
||||
}
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
className="text-sm bg-muted border border-border rounded px-1 py-0.5 w-full outline-none focus:border-primary"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** Destination path picker dialog for copy/move operations */
|
||||
function DestinationPicker({
|
||||
onSubmit,
|
||||
onCancel,
|
||||
defaultPath,
|
||||
action,
|
||||
}: {
|
||||
onSubmit: (path: string) => void;
|
||||
onCancel: () => void;
|
||||
defaultPath: string;
|
||||
action: 'Copy' | 'Move';
|
||||
}) {
|
||||
const [path, setPath] = useState(defaultPath);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
inputRef.current?.select();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||
<div className="bg-background border border-border rounded-lg shadow-lg w-full max-w-md">
|
||||
<div className="px-4 py-3 border-b border-border">
|
||||
<h3 className="text-sm font-medium">{action} To...</h3>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Enter the destination path for the {action.toLowerCase()} operation
|
||||
</p>
|
||||
</div>
|
||||
<div className="px-4 py-3">
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={path}
|
||||
onChange={(e) => setPath(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && path.trim()) {
|
||||
onSubmit(path.trim());
|
||||
} else if (e.key === 'Escape') {
|
||||
onCancel();
|
||||
}
|
||||
}}
|
||||
placeholder="Enter destination path..."
|
||||
className="w-full text-sm bg-muted border border-border rounded px-3 py-2 outline-none focus:border-primary font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2 px-4 py-3 border-t border-border">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="px-3 py-1.5 text-sm rounded hover:bg-muted transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => path.trim() && onSubmit(path.trim())}
|
||||
disabled={!path.trim()}
|
||||
className="px-3 py-1.5 text-sm rounded bg-primary text-primary-foreground hover:bg-primary/90 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{action}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Single tree node renderer */
|
||||
function TreeNode({
|
||||
node,
|
||||
depth,
|
||||
onFileSelect,
|
||||
onCreateFile,
|
||||
onCreateFolder,
|
||||
onDeleteItem,
|
||||
onRenameItem,
|
||||
onCopyPath,
|
||||
onToggleFolder,
|
||||
activeFilePath,
|
||||
gitStatusMap,
|
||||
showHiddenFiles,
|
||||
onCopyItem,
|
||||
onMoveItem,
|
||||
onDownloadItem,
|
||||
onDragDropMove,
|
||||
effectivePath,
|
||||
}: {
|
||||
node: FileTreeNode;
|
||||
depth: number;
|
||||
onFileSelect: (path: string) => void;
|
||||
onCreateFile: (parentPath: string, name: string) => Promise<void>;
|
||||
onCreateFolder: (parentPath: string, name: string) => Promise<void>;
|
||||
onDeleteItem: (path: string, isDirectory: boolean) => Promise<void>;
|
||||
onRenameItem: (oldPath: string, newName: string) => Promise<void>;
|
||||
onCopyPath: (path: string) => void;
|
||||
onToggleFolder: (path: string) => void;
|
||||
activeFilePath: string | null;
|
||||
gitStatusMap: Map<string, string>;
|
||||
showHiddenFiles: boolean;
|
||||
onCopyItem?: (sourcePath: string, destinationPath: string) => Promise<void>;
|
||||
onMoveItem?: (sourcePath: string, destinationPath: string) => Promise<void>;
|
||||
onDownloadItem?: (filePath: string) => Promise<void>;
|
||||
onDragDropMove?: (sourcePaths: string[], targetFolderPath: string) => Promise<void>;
|
||||
effectivePath?: string;
|
||||
}) {
|
||||
const {
|
||||
expandedFolders,
|
||||
enhancedGitStatusMap,
|
||||
dragState,
|
||||
setDragState,
|
||||
selectedPaths,
|
||||
toggleSelectedPath,
|
||||
} = useFileEditorStore();
|
||||
const [isCreatingFile, setIsCreatingFile] = useState(false);
|
||||
const [isCreatingFolder, setIsCreatingFolder] = useState(false);
|
||||
const [isRenaming, setIsRenaming] = useState(false);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [showCopyPicker, setShowCopyPicker] = useState(false);
|
||||
const [showMovePicker, setShowMovePicker] = useState(false);
|
||||
|
||||
const isExpanded = expandedFolders.has(node.path);
|
||||
const isActive = activeFilePath === node.path;
|
||||
const gitStatus = node.gitStatus || gitStatusMap.get(node.path);
|
||||
const statusColor = getGitStatusColor(gitStatus);
|
||||
const statusLabel = getGitStatusLabel(gitStatus);
|
||||
|
||||
// Enhanced git status info
|
||||
const enhancedStatus = enhancedGitStatusMap.get(node.path);
|
||||
const isConflicted = enhancedStatus?.isConflicted || gitStatus === 'U';
|
||||
const isStaged = enhancedStatus?.isStaged || false;
|
||||
const isUnstaged = enhancedStatus?.isUnstaged || false;
|
||||
const linesAdded = enhancedStatus?.linesAdded || 0;
|
||||
const linesRemoved = enhancedStatus?.linesRemoved || 0;
|
||||
const enhancedLabel = enhancedStatus?.statusLabel || statusLabel;
|
||||
|
||||
// Drag state
|
||||
const isDragging = dragState.draggedPaths.includes(node.path);
|
||||
const isDropTarget = dragState.dropTargetPath === node.path && node.isDirectory;
|
||||
const isSelected = selectedPaths.has(node.path);
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
// Multi-select with Ctrl/Cmd
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
e.preventDefault();
|
||||
toggleSelectedPath(node.path);
|
||||
return;
|
||||
}
|
||||
|
||||
if (node.isDirectory) {
|
||||
onToggleFolder(node.path);
|
||||
} else {
|
||||
onFileSelect(node.path);
|
||||
}
|
||||
};
|
||||
|
||||
const handleContextMenu = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
setMenuOpen(true);
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
const itemType = node.isDirectory ? 'folder' : 'file';
|
||||
const confirmed = window.confirm(
|
||||
`Are you sure you want to delete "${node.name}"? This ${itemType} will be moved to trash.`
|
||||
);
|
||||
if (confirmed) {
|
||||
await onDeleteItem(node.path, node.isDirectory);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyName = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(node.name);
|
||||
} catch {
|
||||
// Fallback: silently fail
|
||||
}
|
||||
};
|
||||
|
||||
// Drag handlers
|
||||
const handleDragStart = (e: React.DragEvent) => {
|
||||
e.stopPropagation();
|
||||
const paths = isSelected && selectedPaths.size > 1 ? Array.from(selectedPaths) : [node.path];
|
||||
setDragState({ draggedPaths: paths, dropTargetPath: null });
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('text/plain', JSON.stringify(paths));
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!node.isDirectory) return;
|
||||
|
||||
// Prevent dropping into self or descendant
|
||||
const dragged = dragState.draggedPaths;
|
||||
const isDescendant = dragged.some((p) => node.path === p || node.path.startsWith(p + '/'));
|
||||
if (isDescendant) {
|
||||
e.dataTransfer.dropEffect = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
setDragState({ ...dragState, dropTargetPath: node.path });
|
||||
};
|
||||
|
||||
const handleDragLeave = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (dragState.dropTargetPath === node.path) {
|
||||
setDragState({ ...dragState, dropTargetPath: null });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = async (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragState({ draggedPaths: [], dropTargetPath: null });
|
||||
|
||||
if (!node.isDirectory || !onDragDropMove) return;
|
||||
|
||||
try {
|
||||
const data = e.dataTransfer.getData('text/plain');
|
||||
const paths: string[] = JSON.parse(data);
|
||||
|
||||
// Validate: don't drop into self or descendant
|
||||
const isDescendant = paths.some((p) => node.path === p || node.path.startsWith(p + '/'));
|
||||
if (isDescendant) return;
|
||||
|
||||
await onDragDropMove(paths, node.path);
|
||||
} catch {
|
||||
// Invalid drag data
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragEnd = () => {
|
||||
setDragState({ draggedPaths: [], dropTargetPath: null });
|
||||
};
|
||||
|
||||
// Build tooltip with enhanced info
|
||||
let tooltip = node.name;
|
||||
if (enhancedLabel) tooltip += ` (${enhancedLabel})`;
|
||||
if (linesAdded > 0 || linesRemoved > 0) {
|
||||
tooltip += ` +${linesAdded} -${linesRemoved}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={node.path}>
|
||||
{/* Destination picker dialogs */}
|
||||
{showCopyPicker && onCopyItem && (
|
||||
<DestinationPicker
|
||||
action="Copy"
|
||||
defaultPath={node.path}
|
||||
onSubmit={async (destPath) => {
|
||||
setShowCopyPicker(false);
|
||||
await onCopyItem(node.path, destPath);
|
||||
}}
|
||||
onCancel={() => setShowCopyPicker(false)}
|
||||
/>
|
||||
)}
|
||||
{showMovePicker && onMoveItem && (
|
||||
<DestinationPicker
|
||||
action="Move"
|
||||
defaultPath={node.path}
|
||||
onSubmit={async (destPath) => {
|
||||
setShowMovePicker(false);
|
||||
await onMoveItem(node.path, destPath);
|
||||
}}
|
||||
onCancel={() => setShowMovePicker(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isRenaming ? (
|
||||
<div style={{ paddingLeft: `${depth * 16 + 8}px` }} className="py-0.5 px-2">
|
||||
<InlineInput
|
||||
defaultValue={node.name}
|
||||
onSubmit={async (newName) => {
|
||||
await onRenameItem(node.path, newName);
|
||||
setIsRenaming(false);
|
||||
}}
|
||||
onCancel={() => setIsRenaming(false)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
'group flex items-center gap-1.5 py-0.5 px-2 rounded cursor-pointer text-sm hover:bg-muted/50 relative transition-colors',
|
||||
isActive && 'bg-primary/15 text-primary',
|
||||
statusColor && !isActive && statusColor,
|
||||
isConflicted && 'border-l-2 border-orange-500',
|
||||
isDragging && 'opacity-40',
|
||||
isDropTarget && 'bg-primary/20 ring-1 ring-primary/50',
|
||||
isSelected && !isActive && 'bg-muted/70'
|
||||
)}
|
||||
style={{ paddingLeft: `${depth * 16 + 8}px` }}
|
||||
onClick={handleClick}
|
||||
onContextMenu={handleContextMenu}
|
||||
draggable
|
||||
onDragStart={handleDragStart}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onDragEnd={handleDragEnd}
|
||||
data-testid={`file-tree-item-${node.name}`}
|
||||
title={tooltip}
|
||||
>
|
||||
{/* Drag handle indicator (visible on hover) */}
|
||||
<GripVertical className="w-3 h-3 text-muted-foreground/30 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity hidden md:block" />
|
||||
|
||||
{/* Expand/collapse chevron */}
|
||||
{node.isDirectory ? (
|
||||
isExpanded ? (
|
||||
<ChevronDown className="w-3.5 h-3.5 text-muted-foreground shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="w-3.5 h-3.5 text-muted-foreground shrink-0" />
|
||||
)
|
||||
) : (
|
||||
<span className="w-3.5 shrink-0" />
|
||||
)}
|
||||
|
||||
{/* Icon */}
|
||||
{node.isDirectory ? (
|
||||
isExpanded ? (
|
||||
<FolderOpen className="w-4 h-4 text-primary shrink-0" />
|
||||
) : (
|
||||
<Folder className="w-4 h-4 text-primary shrink-0" />
|
||||
)
|
||||
) : isConflicted ? (
|
||||
<AlertTriangle className="w-4 h-4 text-orange-500 shrink-0" />
|
||||
) : (
|
||||
<File className="w-4 h-4 text-muted-foreground shrink-0" />
|
||||
)}
|
||||
|
||||
{/* Name */}
|
||||
<span className="truncate flex-1">{node.name}</span>
|
||||
|
||||
{/* Diff stats (lines added/removed) shown inline */}
|
||||
{!node.isDirectory && (linesAdded > 0 || linesRemoved > 0) && (
|
||||
<span className="flex items-center gap-1 text-[10px] shrink-0 opacity-70">
|
||||
{linesAdded > 0 && (
|
||||
<span className="flex items-center text-green-600">
|
||||
<Plus className="w-2.5 h-2.5" />
|
||||
{linesAdded}
|
||||
</span>
|
||||
)}
|
||||
{linesRemoved > 0 && (
|
||||
<span className="flex items-center text-red-500">
|
||||
<Minus className="w-2.5 h-2.5" />
|
||||
{linesRemoved}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Git status indicator - two-tone badge for staged+unstaged */}
|
||||
{gitStatus && (
|
||||
<span className="flex items-center gap-0 shrink-0">
|
||||
{isStaged && isUnstaged ? (
|
||||
// Two-tone badge: staged (green) + unstaged (yellow)
|
||||
<>
|
||||
<span
|
||||
className="w-1.5 h-1.5 rounded-l-full bg-green-500"
|
||||
title="Staged changes"
|
||||
/>
|
||||
<span
|
||||
className="w-1.5 h-1.5 rounded-r-full bg-yellow-500"
|
||||
title="Unstaged changes"
|
||||
/>
|
||||
</>
|
||||
) : isConflicted ? (
|
||||
<span
|
||||
className="w-1.5 h-1.5 rounded-full bg-orange-500 animate-pulse"
|
||||
title="Conflicted"
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className={cn('w-1.5 h-1.5 rounded-full shrink-0', {
|
||||
'bg-yellow-500': gitStatus === 'M',
|
||||
'bg-green-500': gitStatus === 'A' || gitStatus === 'S',
|
||||
'bg-red-500': gitStatus === 'D',
|
||||
'bg-gray-400': gitStatus === '?',
|
||||
'bg-gray-600': gitStatus === '!',
|
||||
'bg-purple-500': gitStatus === 'R',
|
||||
'bg-cyan-500': gitStatus === 'C',
|
||||
'bg-orange-500': gitStatus === 'U',
|
||||
})}
|
||||
title={enhancedLabel || statusLabel}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Actions dropdown menu (three-dot button) */}
|
||||
<DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
className={cn(
|
||||
'p-0.5 rounded shrink-0 hover:bg-accent transition-opacity',
|
||||
// On mobile (max-md): always visible for touch access
|
||||
// On desktop (md+): show on hover, focus, or when menu is open
|
||||
'max-md:opacity-100 md:opacity-0 md:group-hover:opacity-100 focus:opacity-100',
|
||||
menuOpen && 'opacity-100'
|
||||
)}
|
||||
data-testid={`file-tree-menu-${node.name}`}
|
||||
aria-label={`Actions for ${node.name}`}
|
||||
>
|
||||
<MoreVertical className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" side="right" className="w-48">
|
||||
{/* Folder-specific: New File / New Folder */}
|
||||
{node.isDirectory && (
|
||||
<>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (!isExpanded) onToggleFolder(node.path);
|
||||
setIsCreatingFile(true);
|
||||
}}
|
||||
className="gap-2"
|
||||
>
|
||||
<FilePlus className="w-4 h-4" />
|
||||
<span>New File</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (!isExpanded) onToggleFolder(node.path);
|
||||
setIsCreatingFolder(true);
|
||||
}}
|
||||
className="gap-2"
|
||||
>
|
||||
<FolderPlus className="w-4 h-4" />
|
||||
<span>New Folder</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Copy operations */}
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onCopyPath(node.path);
|
||||
}}
|
||||
className="gap-2"
|
||||
>
|
||||
<ClipboardCopy className="w-4 h-4" />
|
||||
<span>Copy Path</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleCopyName();
|
||||
}}
|
||||
className="gap-2"
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
<span>Copy Name</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
{/* Copy To... */}
|
||||
{onCopyItem && (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowCopyPicker(true);
|
||||
}}
|
||||
className="gap-2"
|
||||
>
|
||||
<FolderInput className="w-4 h-4" />
|
||||
<span>Copy To...</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{/* Move To... */}
|
||||
{onMoveItem && (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowMovePicker(true);
|
||||
}}
|
||||
className="gap-2"
|
||||
>
|
||||
<FolderOutput className="w-4 h-4" />
|
||||
<span>Move To...</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{/* Download */}
|
||||
{onDownloadItem && (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDownloadItem(node.path);
|
||||
}}
|
||||
className="gap-2"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
<span>Download{node.isDirectory ? ' as ZIP' : ''}</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
{/* Rename */}
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsRenaming(true);
|
||||
}}
|
||||
className="gap-2"
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
<span>Rename</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
{/* Delete */}
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDelete();
|
||||
}}
|
||||
className="gap-2 text-destructive focus:text-destructive focus:bg-destructive/10"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
<span>Delete</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Children (expanded folder) */}
|
||||
{node.isDirectory && isExpanded && node.children && (
|
||||
<div>
|
||||
{/* Inline create file input */}
|
||||
{isCreatingFile && (
|
||||
<div style={{ paddingLeft: `${(depth + 1) * 16 + 8}px` }} className="py-0.5 px-2">
|
||||
<InlineInput
|
||||
placeholder="filename.ext"
|
||||
onSubmit={async (name) => {
|
||||
await onCreateFile(node.path, name);
|
||||
setIsCreatingFile(false);
|
||||
}}
|
||||
onCancel={() => setIsCreatingFile(false)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* Inline create folder input */}
|
||||
{isCreatingFolder && (
|
||||
<div style={{ paddingLeft: `${(depth + 1) * 16 + 8}px` }} className="py-0.5 px-2">
|
||||
<InlineInput
|
||||
placeholder="folder-name"
|
||||
onSubmit={async (name) => {
|
||||
await onCreateFolder(node.path, name);
|
||||
setIsCreatingFolder(false);
|
||||
}}
|
||||
onCancel={() => setIsCreatingFolder(false)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{(showHiddenFiles
|
||||
? node.children
|
||||
: node.children.filter((child) => !child.name.startsWith('.'))
|
||||
).map((child) => (
|
||||
<TreeNode
|
||||
key={child.path}
|
||||
node={child}
|
||||
depth={depth + 1}
|
||||
onFileSelect={onFileSelect}
|
||||
onCreateFile={onCreateFile}
|
||||
onCreateFolder={onCreateFolder}
|
||||
onDeleteItem={onDeleteItem}
|
||||
onRenameItem={onRenameItem}
|
||||
onCopyPath={onCopyPath}
|
||||
onToggleFolder={onToggleFolder}
|
||||
activeFilePath={activeFilePath}
|
||||
gitStatusMap={gitStatusMap}
|
||||
showHiddenFiles={showHiddenFiles}
|
||||
onCopyItem={onCopyItem}
|
||||
onMoveItem={onMoveItem}
|
||||
onDownloadItem={onDownloadItem}
|
||||
onDragDropMove={onDragDropMove}
|
||||
effectivePath={effectivePath}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FileTree({
|
||||
onFileSelect,
|
||||
onCreateFile,
|
||||
onCreateFolder,
|
||||
onDeleteItem,
|
||||
onRenameItem,
|
||||
onCopyPath,
|
||||
onRefresh,
|
||||
onToggleFolder,
|
||||
activeFilePath,
|
||||
onCopyItem,
|
||||
onMoveItem,
|
||||
onDownloadItem,
|
||||
onDragDropMove,
|
||||
effectivePath,
|
||||
}: FileTreeProps) {
|
||||
const { fileTree, showHiddenFiles, setShowHiddenFiles, gitStatusMap, setDragState, gitBranch } =
|
||||
useFileEditorStore();
|
||||
const [isCreatingFile, setIsCreatingFile] = useState(false);
|
||||
const [isCreatingFolder, setIsCreatingFolder] = useState(false);
|
||||
|
||||
// Filter hidden files if needed
|
||||
const filteredTree = showHiddenFiles
|
||||
? fileTree
|
||||
: fileTree.filter((node) => !node.name.startsWith('.'));
|
||||
|
||||
// Handle drop on root area
|
||||
const handleRootDragOver = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
if (effectivePath) {
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
setDragState({ draggedPaths: [], dropTargetPath: effectivePath });
|
||||
}
|
||||
},
|
||||
[effectivePath, setDragState]
|
||||
);
|
||||
|
||||
const handleRootDrop = useCallback(
|
||||
async (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setDragState({ draggedPaths: [], dropTargetPath: null });
|
||||
|
||||
if (!effectivePath || !onDragDropMove) return;
|
||||
|
||||
try {
|
||||
const data = e.dataTransfer.getData('text/plain');
|
||||
const paths: string[] = JSON.parse(data);
|
||||
await onDragDropMove(paths, effectivePath);
|
||||
} catch {
|
||||
// Invalid drag data
|
||||
}
|
||||
},
|
||||
[effectivePath, onDragDropMove, setDragState]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full" data-testid="file-tree">
|
||||
{/* Tree toolbar */}
|
||||
<div className="flex items-center justify-between px-2 py-1.5 border-b border-border">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Explorer
|
||||
</span>
|
||||
{gitBranch && (
|
||||
<span className="text-[10px] text-primary font-medium px-1 py-0.5 bg-primary/10 rounded">
|
||||
{gitBranch}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<button
|
||||
onClick={() => setIsCreatingFile(true)}
|
||||
className="p-1 hover:bg-accent rounded"
|
||||
title="New file"
|
||||
>
|
||||
<FilePlus className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsCreatingFolder(true)}
|
||||
className="p-1 hover:bg-accent rounded"
|
||||
title="New folder"
|
||||
>
|
||||
<FolderPlus className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowHiddenFiles(!showHiddenFiles)}
|
||||
className="p-1 hover:bg-accent rounded"
|
||||
title={showHiddenFiles ? 'Hide dotfiles' : 'Show dotfiles'}
|
||||
>
|
||||
{showHiddenFiles ? (
|
||||
<Eye className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
) : (
|
||||
<EyeOff className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
<button onClick={onRefresh} className="p-1 hover:bg-accent rounded" title="Refresh">
|
||||
<RefreshCw className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tree content */}
|
||||
<div
|
||||
className="flex-1 overflow-y-auto py-1"
|
||||
onDragOver={handleRootDragOver}
|
||||
onDrop={handleRootDrop}
|
||||
>
|
||||
{/* Root-level inline creators */}
|
||||
{isCreatingFile && (
|
||||
<div className="py-0.5 px-2" style={{ paddingLeft: '8px' }}>
|
||||
<InlineInput
|
||||
placeholder="filename.ext"
|
||||
onSubmit={async (name) => {
|
||||
await onCreateFile('', name);
|
||||
setIsCreatingFile(false);
|
||||
}}
|
||||
onCancel={() => setIsCreatingFile(false)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{isCreatingFolder && (
|
||||
<div className="py-0.5 px-2" style={{ paddingLeft: '8px' }}>
|
||||
<InlineInput
|
||||
placeholder="folder-name"
|
||||
onSubmit={async (name) => {
|
||||
await onCreateFolder('', name);
|
||||
setIsCreatingFolder(false);
|
||||
}}
|
||||
onCancel={() => setIsCreatingFolder(false)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filteredTree.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<p className="text-xs text-muted-foreground">No files found</p>
|
||||
</div>
|
||||
) : (
|
||||
filteredTree.map((node) => (
|
||||
<TreeNode
|
||||
key={node.path}
|
||||
node={node}
|
||||
depth={0}
|
||||
onFileSelect={onFileSelect}
|
||||
onCreateFile={onCreateFile}
|
||||
onCreateFolder={onCreateFolder}
|
||||
onDeleteItem={onDeleteItem}
|
||||
onRenameItem={onRenameItem}
|
||||
onCopyPath={onCopyPath}
|
||||
onToggleFolder={onToggleFolder}
|
||||
activeFilePath={activeFilePath}
|
||||
gitStatusMap={gitStatusMap}
|
||||
showHiddenFiles={showHiddenFiles}
|
||||
onCopyItem={onCopyItem}
|
||||
onMoveItem={onMoveItem}
|
||||
onDownloadItem={onDownloadItem}
|
||||
onDragDropMove={onDragDropMove}
|
||||
effectivePath={effectivePath}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
GitBranch,
|
||||
GitCommit,
|
||||
User,
|
||||
Clock,
|
||||
Plus,
|
||||
Minus,
|
||||
AlertTriangle,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
FileEdit,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { GitFileDetailsInfo } from '../use-file-editor-store';
|
||||
|
||||
interface GitDetailPanelProps {
|
||||
details: GitFileDetailsInfo;
|
||||
filePath: string;
|
||||
onOpenFile?: (path: string) => void;
|
||||
}
|
||||
|
||||
export function GitDetailPanel({ details, filePath, onOpenFile }: GitDetailPanelProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
// Don't show anything if there's no meaningful data
|
||||
if (!details.branch && !details.lastCommitHash && !details.statusLabel) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hasChanges = details.linesAdded > 0 || details.linesRemoved > 0;
|
||||
const commitHashShort = details.lastCommitHash ? details.lastCommitHash.substring(0, 7) : '';
|
||||
const timeAgo = details.lastCommitTimestamp ? formatTimeAgo(details.lastCommitTimestamp) : '';
|
||||
|
||||
return (
|
||||
<div className="border-t border-border bg-muted/20">
|
||||
{/* Collapsed summary bar */}
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="w-full flex items-center justify-between px-3 py-1 text-xs text-muted-foreground hover:bg-muted/40 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Branch */}
|
||||
{details.branch && (
|
||||
<span className="flex items-center gap-1">
|
||||
<GitBranch className="w-3 h-3" />
|
||||
<span className="text-primary font-medium">{details.branch}</span>
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Status label with visual treatment */}
|
||||
{details.statusLabel && (
|
||||
<span
|
||||
className={cn('px-1.5 py-0.5 rounded text-[10px] font-medium uppercase', {
|
||||
'bg-yellow-500/15 text-yellow-600': details.statusLabel === 'Modified',
|
||||
'bg-green-500/15 text-green-600':
|
||||
details.statusLabel === 'Added' || details.statusLabel === 'Staged',
|
||||
'bg-red-500/15 text-red-600': details.statusLabel === 'Deleted',
|
||||
'bg-purple-500/15 text-purple-600': details.statusLabel === 'Renamed',
|
||||
'bg-gray-500/15 text-gray-500': details.statusLabel === 'Untracked',
|
||||
'bg-orange-500/15 text-orange-600':
|
||||
details.statusLabel === 'Conflicted' || details.isConflicted,
|
||||
'bg-blue-500/15 text-blue-600': details.statusLabel === 'Staged + Modified',
|
||||
})}
|
||||
>
|
||||
{details.isConflicted && <AlertTriangle className="w-3 h-3 inline mr-0.5" />}
|
||||
{details.statusLabel}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Staged/unstaged two-tone badge */}
|
||||
{details.isStaged && details.isUnstaged && (
|
||||
<span className="flex items-center gap-0">
|
||||
<span className="w-2 h-2 rounded-l bg-green-500" title="Staged changes" />
|
||||
<span className="w-2 h-2 rounded-r bg-yellow-500" title="Unstaged changes" />
|
||||
</span>
|
||||
)}
|
||||
{details.isStaged && !details.isUnstaged && (
|
||||
<span className="w-2 h-2 rounded bg-green-500" title="Staged" />
|
||||
)}
|
||||
{!details.isStaged && details.isUnstaged && (
|
||||
<span className="w-2 h-2 rounded bg-yellow-500" title="Unstaged" />
|
||||
)}
|
||||
|
||||
{/* Diff stats */}
|
||||
{hasChanges && (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="flex items-center gap-0.5 text-green-600">
|
||||
<Plus className="w-3 h-3" />
|
||||
{details.linesAdded}
|
||||
</span>
|
||||
<span className="flex items-center gap-0.5 text-red-500">
|
||||
<Minus className="w-3 h-3" />
|
||||
{details.linesRemoved}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{commitHashShort && (
|
||||
<span className="flex items-center gap-1 text-muted-foreground/70">
|
||||
<GitCommit className="w-3 h-3" />
|
||||
{commitHashShort}
|
||||
</span>
|
||||
)}
|
||||
{isExpanded ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Expanded details */}
|
||||
{isExpanded && (
|
||||
<div className="px-3 py-2 border-t border-border/50 space-y-1.5 text-xs text-muted-foreground">
|
||||
{/* Last commit info */}
|
||||
{details.lastCommitHash && (
|
||||
<>
|
||||
<div className="flex items-start gap-2">
|
||||
<GitCommit className="w-3.5 h-3.5 mt-0.5 shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<div className="font-mono text-foreground/80">{commitHashShort}</div>
|
||||
{details.lastCommitMessage && (
|
||||
<div className="text-muted-foreground truncate">
|
||||
{details.lastCommitMessage}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{details.lastCommitAuthor && (
|
||||
<div className="flex items-center gap-2">
|
||||
<User className="w-3.5 h-3.5 shrink-0" />
|
||||
<span>{details.lastCommitAuthor}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{timeAgo && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="w-3.5 h-3.5 shrink-0" />
|
||||
<span>{timeAgo}</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Conflict warning with action */}
|
||||
{details.isConflicted && (
|
||||
<div className="flex items-center gap-2 p-2 rounded bg-orange-500/10 border border-orange-500/20 text-orange-600">
|
||||
<AlertTriangle className="w-4 h-4 shrink-0" />
|
||||
<span className="flex-1 font-medium">This file has merge conflicts</span>
|
||||
{onOpenFile && (
|
||||
<button
|
||||
onClick={() => onOpenFile(filePath)}
|
||||
className="flex items-center gap-1 px-2 py-0.5 rounded bg-orange-500/20 hover:bg-orange-500/30 text-orange-700 text-[10px] font-medium transition-colors"
|
||||
>
|
||||
<FileEdit className="w-3 h-3" />
|
||||
Resolve
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Format an ISO timestamp as a human-readable relative time */
|
||||
function formatTimeAgo(isoTimestamp: string): string {
|
||||
try {
|
||||
const date = new Date(isoTimestamp);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffSecs = Math.floor(diffMs / 1000);
|
||||
const diffMins = Math.floor(diffSecs / 60);
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
|
||||
if (diffSecs < 60) return 'just now';
|
||||
if (diffMins < 60) return `${diffMins} min${diffMins > 1 ? 's' : ''} ago`;
|
||||
if (diffHours < 24) return `${diffHours} hour${diffHours > 1 ? 's' : ''} ago`;
|
||||
if (diffDays < 30) return `${diffDays} day${diffDays > 1 ? 's' : ''} ago`;
|
||||
|
||||
return date.toLocaleDateString();
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { useRef } from 'react';
|
||||
import { Columns2, Eye, Code2 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Markdown } from '@/components/ui/markdown';
|
||||
import type { MarkdownViewMode } from '../use-file-editor-store';
|
||||
|
||||
/** Toolbar for switching between editor/preview/split modes */
|
||||
export function MarkdownViewToolbar({
|
||||
viewMode,
|
||||
onViewModeChange,
|
||||
}: {
|
||||
viewMode: MarkdownViewMode;
|
||||
onViewModeChange: (mode: MarkdownViewMode) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-0.5 bg-muted/50 rounded-md p-0.5">
|
||||
<button
|
||||
onClick={() => onViewModeChange('editor')}
|
||||
className={cn(
|
||||
'flex items-center gap-1 px-2 py-1 rounded text-xs transition-colors',
|
||||
viewMode === 'editor'
|
||||
? 'bg-background text-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
title="Editor only"
|
||||
>
|
||||
<Code2 className="w-3 h-3" />
|
||||
<span>Edit</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onViewModeChange('split')}
|
||||
className={cn(
|
||||
'flex items-center gap-1 px-2 py-1 rounded text-xs transition-colors',
|
||||
viewMode === 'split'
|
||||
? 'bg-background text-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
title="Split view"
|
||||
>
|
||||
<Columns2 className="w-3 h-3" />
|
||||
<span>Split</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onViewModeChange('preview')}
|
||||
className={cn(
|
||||
'flex items-center gap-1 px-2 py-1 rounded text-xs transition-colors',
|
||||
viewMode === 'preview'
|
||||
? 'bg-background text-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
title="Preview only"
|
||||
>
|
||||
<Eye className="w-3 h-3" />
|
||||
<span>Preview</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Rendered markdown preview panel */
|
||||
export function MarkdownPreviewPanel({
|
||||
content,
|
||||
className,
|
||||
}: {
|
||||
content: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className={cn('h-full overflow-y-auto bg-background/50 p-6', className)}
|
||||
data-testid="markdown-preview"
|
||||
>
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<Markdown>{content || '*No content to preview*'}</Markdown>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Check if a file is a markdown file */
|
||||
export function isMarkdownFile(filePath: string): boolean {
|
||||
const fileName = filePath.split('/').pop() || '';
|
||||
const dotIndex = fileName.lastIndexOf('.');
|
||||
const ext = dotIndex > 0 ? fileName.slice(dotIndex + 1).toLowerCase() : '';
|
||||
return ['md', 'mdx', 'markdown'].includes(ext);
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* WorktreeDirectoryDropdown
|
||||
*
|
||||
* A dropdown for the file editor header that allows the user to select which
|
||||
* worktree directory to work from (or the main project directory).
|
||||
*
|
||||
* Reads the current worktree selection from the app store so that when a user
|
||||
* is on a worktree in the board view and then navigates to the file editor,
|
||||
* it defaults to that worktree directory.
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { GitBranch, ChevronDown, Check, FolderRoot } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { useWorktrees } from '@/hooks/queries';
|
||||
import { pathsEqual } from '@/lib/utils';
|
||||
|
||||
interface WorktreeDirectoryDropdownProps {
|
||||
projectPath: string;
|
||||
}
|
||||
|
||||
// Stable empty array to avoid creating a new reference every render when there are no worktrees.
|
||||
// Zustand compares selector results by reference; returning `[]` inline (e.g. via `?? []`) creates
|
||||
// a new array on every call, causing `forceStoreRerender` to trigger an infinite update loop.
|
||||
const EMPTY_WORKTREES: never[] = [];
|
||||
|
||||
export function WorktreeDirectoryDropdown({ projectPath }: WorktreeDirectoryDropdownProps) {
|
||||
// Select primitive/stable values directly from the store to prevent infinite re-renders.
|
||||
// Computed selectors that return new arrays/objects on every call (e.g. via `?? []`)
|
||||
// are compared by reference, causing Zustand to force re-renders on every store update.
|
||||
const currentWorktree = useAppStore((s) => s.currentWorktreeByProject[projectPath] ?? null);
|
||||
const setCurrentWorktree = useAppStore((s) => s.setCurrentWorktree);
|
||||
const worktreesInStore = useAppStore((s) => s.worktreesByProject[projectPath] ?? EMPTY_WORKTREES);
|
||||
const useWorktreesEnabled = useAppStore((s) => {
|
||||
const projectOverride = s.useWorktreesByProject[projectPath];
|
||||
return projectOverride !== undefined ? projectOverride : s.useWorktrees;
|
||||
});
|
||||
|
||||
// Fetch worktrees from query
|
||||
const { data } = useWorktrees(projectPath);
|
||||
const worktrees = useMemo(() => data?.worktrees ?? [], [data?.worktrees]);
|
||||
|
||||
// Also consider store worktrees as fallback
|
||||
const effectiveWorktrees = worktrees.length > 0 ? worktrees : worktreesInStore;
|
||||
|
||||
// Don't render if worktrees are not enabled or only the main branch exists
|
||||
if (!useWorktreesEnabled || effectiveWorktrees.length <= 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const currentWorktreePath = currentWorktree?.path ?? null;
|
||||
const currentBranch = currentWorktree?.branch ?? 'main';
|
||||
|
||||
// Find main worktree
|
||||
const mainWorktree = effectiveWorktrees.find((w) => w.isMain);
|
||||
const otherWorktrees = effectiveWorktrees.filter((w) => !w.isMain);
|
||||
|
||||
// Determine display name for the selected worktree
|
||||
const selectedIsMain = currentWorktreePath === null;
|
||||
const selectedBranchName = selectedIsMain ? (mainWorktree?.branch ?? 'main') : currentBranch;
|
||||
|
||||
// Truncate long branch names for the trigger button
|
||||
const maxTriggerLength = 20;
|
||||
const displayName =
|
||||
selectedBranchName.length > maxTriggerLength
|
||||
? `${selectedBranchName.slice(0, maxTriggerLength)}...`
|
||||
: selectedBranchName;
|
||||
|
||||
const handleSelectWorktree = (worktreePath: string | null, branch: string) => {
|
||||
setCurrentWorktree(projectPath, worktreePath, branch);
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-1.5 max-w-[200px] text-xs"
|
||||
title={`Working directory: ${selectedBranchName}`}
|
||||
>
|
||||
<GitBranch className="w-3.5 h-3.5 shrink-0" />
|
||||
<span className="truncate">{displayName}</span>
|
||||
<ChevronDown className="w-3 h-3 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-[240px]">
|
||||
<DropdownMenuLabel className="text-xs text-muted-foreground">
|
||||
Working Directory
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
{/* Main directory */}
|
||||
{mainWorktree && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleSelectWorktree(null, mainWorktree.branch)}
|
||||
className="gap-2"
|
||||
>
|
||||
<FolderRoot className="w-3.5 h-3.5 shrink-0 text-muted-foreground" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="truncate block text-sm">{mainWorktree.branch}</span>
|
||||
<span className="text-xs text-muted-foreground">Main directory</span>
|
||||
</div>
|
||||
{selectedIsMain && <Check className="w-3.5 h-3.5 shrink-0 text-primary" />}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{/* Worktree directories */}
|
||||
{otherWorktrees.length > 0 && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel className="text-xs text-muted-foreground">
|
||||
Worktrees
|
||||
</DropdownMenuLabel>
|
||||
{otherWorktrees.map((wt) => {
|
||||
const isSelected =
|
||||
currentWorktreePath !== null && pathsEqual(wt.path, currentWorktreePath);
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={wt.path}
|
||||
onClick={() => handleSelectWorktree(wt.path, wt.branch)}
|
||||
className="gap-2"
|
||||
>
|
||||
<GitBranch className="w-3.5 h-3.5 shrink-0 text-muted-foreground" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="truncate block text-sm">{wt.branch}</span>
|
||||
{wt.hasChanges && (
|
||||
<span className="text-xs text-amber-500">
|
||||
{wt.changedFilesCount ?? ''} change{wt.changedFilesCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{isSelected && <Check className="w-3.5 h-3.5 shrink-0 text-primary" />}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
1492
apps/ui/src/components/views/file-editor-view/file-editor-view.tsx
Normal file
1492
apps/ui/src/components/views/file-editor-view/file-editor-view.tsx
Normal file
File diff suppressed because it is too large
Load Diff
1
apps/ui/src/components/views/file-editor-view/index.ts
Normal file
1
apps/ui/src/components/views/file-editor-view/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { FileEditorView } from './file-editor-view';
|
||||
@@ -0,0 +1,371 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist, type StorageValue } from 'zustand/middleware';
|
||||
|
||||
export interface FileTreeNode {
|
||||
name: string;
|
||||
path: string;
|
||||
isDirectory: boolean;
|
||||
children?: FileTreeNode[];
|
||||
/** Git status indicator: M=modified, A=added, D=deleted, ?=untracked, !=ignored, S=staged */
|
||||
gitStatus?: string;
|
||||
}
|
||||
|
||||
export interface EditorTab {
|
||||
id: string;
|
||||
filePath: string;
|
||||
fileName: string;
|
||||
content: string;
|
||||
originalContent: string;
|
||||
isDirty: boolean;
|
||||
scrollTop: number;
|
||||
cursorLine: number;
|
||||
cursorCol: number;
|
||||
/** Whether the file is binary (non-editable) */
|
||||
isBinary: boolean;
|
||||
/** Whether the file is too large to edit */
|
||||
isTooLarge: boolean;
|
||||
/** File size in bytes */
|
||||
fileSize: number;
|
||||
}
|
||||
|
||||
export type MarkdownViewMode = 'editor' | 'preview' | 'split';
|
||||
|
||||
/** Enhanced git status per file, including diff stats and staged/unstaged info */
|
||||
export interface EnhancedGitFileStatus {
|
||||
indexStatus: string;
|
||||
workTreeStatus: string;
|
||||
isConflicted: boolean;
|
||||
isStaged: boolean;
|
||||
isUnstaged: boolean;
|
||||
linesAdded: number;
|
||||
linesRemoved: number;
|
||||
statusLabel: string;
|
||||
}
|
||||
|
||||
/** Git details for a specific file (shown in detail panel) */
|
||||
export interface GitFileDetailsInfo {
|
||||
branch: string;
|
||||
lastCommitHash: string;
|
||||
lastCommitMessage: string;
|
||||
lastCommitAuthor: string;
|
||||
lastCommitTimestamp: string;
|
||||
linesAdded: number;
|
||||
linesRemoved: number;
|
||||
isConflicted: boolean;
|
||||
isStaged: boolean;
|
||||
isUnstaged: boolean;
|
||||
statusLabel: string;
|
||||
}
|
||||
|
||||
/** Items being dragged in the file tree */
|
||||
export interface DragState {
|
||||
/** Paths of items currently being dragged */
|
||||
draggedPaths: string[];
|
||||
/** Path of the current drop target folder */
|
||||
dropTargetPath: string | null;
|
||||
}
|
||||
|
||||
interface FileEditorState {
|
||||
// File tree state
|
||||
fileTree: FileTreeNode[];
|
||||
expandedFolders: Set<string>;
|
||||
showHiddenFiles: boolean;
|
||||
|
||||
// Editor tabs
|
||||
tabs: EditorTab[];
|
||||
activeTabId: string | null;
|
||||
|
||||
// Markdown preview
|
||||
markdownViewMode: MarkdownViewMode;
|
||||
|
||||
// Mobile layout state
|
||||
/** Whether the file browser is visible on mobile (defaults to true) */
|
||||
mobileBrowserVisible: boolean;
|
||||
|
||||
// Settings
|
||||
tabSize: number;
|
||||
wordWrap: boolean;
|
||||
fontSize: number;
|
||||
/** Maximum file size in bytes before warning (default 1MB) */
|
||||
maxFileSize: number;
|
||||
|
||||
// Git status map: filePath -> status
|
||||
gitStatusMap: Map<string, string>;
|
||||
|
||||
// Enhanced git status: filePath -> enhanced status info
|
||||
enhancedGitStatusMap: Map<string, EnhancedGitFileStatus>;
|
||||
|
||||
// Current branch name
|
||||
gitBranch: string;
|
||||
|
||||
// Git details for the currently active file (loaded on demand)
|
||||
activeFileGitDetails: GitFileDetailsInfo | null;
|
||||
|
||||
// Drag and drop state
|
||||
dragState: DragState;
|
||||
|
||||
// Selected items for multi-select operations
|
||||
selectedPaths: Set<string>;
|
||||
|
||||
// Actions
|
||||
setFileTree: (tree: FileTreeNode[]) => void;
|
||||
toggleFolder: (path: string) => void;
|
||||
setShowHiddenFiles: (show: boolean) => void;
|
||||
setExpandedFolders: (folders: Set<string>) => void;
|
||||
|
||||
openTab: (tab: Omit<EditorTab, 'id'>) => void;
|
||||
closeTab: (tabId: string) => void;
|
||||
closeAllTabs: () => void;
|
||||
setActiveTab: (tabId: string) => void;
|
||||
updateTabContent: (tabId: string, content: string) => void;
|
||||
markTabSaved: (tabId: string, content: string) => void;
|
||||
updateTabScroll: (tabId: string, scrollTop: number) => void;
|
||||
updateTabCursor: (tabId: string, line: number, col: number) => void;
|
||||
|
||||
setMarkdownViewMode: (mode: MarkdownViewMode) => void;
|
||||
|
||||
setMobileBrowserVisible: (visible: boolean) => void;
|
||||
|
||||
setTabSize: (size: number) => void;
|
||||
setWordWrap: (wrap: boolean) => void;
|
||||
setFontSize: (size: number) => void;
|
||||
|
||||
setGitStatusMap: (map: Map<string, string>) => void;
|
||||
setEnhancedGitStatusMap: (map: Map<string, EnhancedGitFileStatus>) => void;
|
||||
setGitBranch: (branch: string) => void;
|
||||
setActiveFileGitDetails: (details: GitFileDetailsInfo | null) => void;
|
||||
|
||||
setDragState: (state: DragState) => void;
|
||||
setSelectedPaths: (paths: Set<string>) => void;
|
||||
toggleSelectedPath: (path: string) => void;
|
||||
clearSelectedPaths: () => void;
|
||||
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
const initialState = {
|
||||
fileTree: [] as FileTreeNode[],
|
||||
expandedFolders: new Set<string>(),
|
||||
showHiddenFiles: true,
|
||||
tabs: [] as EditorTab[],
|
||||
activeTabId: null as string | null,
|
||||
markdownViewMode: 'split' as MarkdownViewMode,
|
||||
mobileBrowserVisible: true,
|
||||
tabSize: 2,
|
||||
wordWrap: true,
|
||||
fontSize: 13,
|
||||
maxFileSize: 1024 * 1024, // 1MB
|
||||
gitStatusMap: new Map<string, string>(),
|
||||
enhancedGitStatusMap: new Map<string, EnhancedGitFileStatus>(),
|
||||
gitBranch: '',
|
||||
activeFileGitDetails: null as GitFileDetailsInfo | null,
|
||||
dragState: { draggedPaths: [], dropTargetPath: null } as DragState,
|
||||
selectedPaths: new Set<string>(),
|
||||
};
|
||||
|
||||
/** Shape of the persisted subset (Sets are stored as arrays for JSON compatibility) */
|
||||
interface PersistedFileEditorState {
|
||||
tabs: EditorTab[];
|
||||
activeTabId: string | null;
|
||||
expandedFolders: string[];
|
||||
markdownViewMode: MarkdownViewMode;
|
||||
}
|
||||
|
||||
const STORE_NAME = 'automaker-file-editor';
|
||||
|
||||
export const useFileEditorStore = create<FileEditorState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
...initialState,
|
||||
|
||||
setFileTree: (tree) => set({ fileTree: tree }),
|
||||
|
||||
toggleFolder: (path) => {
|
||||
const { expandedFolders } = get();
|
||||
const next = new Set(expandedFolders);
|
||||
if (next.has(path)) {
|
||||
next.delete(path);
|
||||
} else {
|
||||
next.add(path);
|
||||
}
|
||||
set({ expandedFolders: next });
|
||||
},
|
||||
|
||||
setShowHiddenFiles: (show) => set({ showHiddenFiles: show }),
|
||||
|
||||
setExpandedFolders: (folders) => set({ expandedFolders: folders }),
|
||||
|
||||
openTab: (tabData) => {
|
||||
const { tabs } = get();
|
||||
// Check if file is already open
|
||||
const existing = tabs.find((t) => t.filePath === tabData.filePath);
|
||||
if (existing) {
|
||||
set({ activeTabId: existing.id });
|
||||
return;
|
||||
}
|
||||
|
||||
const id = `tab-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const newTab: EditorTab = { ...tabData, id };
|
||||
set({
|
||||
tabs: [...tabs, newTab],
|
||||
activeTabId: id,
|
||||
});
|
||||
},
|
||||
|
||||
closeTab: (tabId) => {
|
||||
const { tabs, activeTabId } = get();
|
||||
const idx = tabs.findIndex((t) => t.id === tabId);
|
||||
if (idx === -1) return;
|
||||
|
||||
const newTabs = tabs.filter((t) => t.id !== tabId);
|
||||
let newActiveId = activeTabId;
|
||||
|
||||
if (activeTabId === tabId) {
|
||||
if (newTabs.length === 0) {
|
||||
newActiveId = null;
|
||||
} else if (idx >= newTabs.length) {
|
||||
newActiveId = newTabs[newTabs.length - 1].id;
|
||||
} else {
|
||||
newActiveId = newTabs[idx].id;
|
||||
}
|
||||
}
|
||||
|
||||
set({ tabs: newTabs, activeTabId: newActiveId });
|
||||
},
|
||||
|
||||
closeAllTabs: () => {
|
||||
set({ tabs: [], activeTabId: null });
|
||||
},
|
||||
|
||||
setActiveTab: (tabId) => set({ activeTabId: tabId }),
|
||||
|
||||
updateTabContent: (tabId, content) => {
|
||||
set({
|
||||
tabs: get().tabs.map((t) =>
|
||||
t.id === tabId ? { ...t, content, isDirty: content !== t.originalContent } : t
|
||||
),
|
||||
});
|
||||
},
|
||||
|
||||
markTabSaved: (tabId, content) => {
|
||||
set({
|
||||
tabs: get().tabs.map((t) =>
|
||||
t.id === tabId ? { ...t, content, originalContent: content, isDirty: false } : t
|
||||
),
|
||||
});
|
||||
},
|
||||
|
||||
updateTabScroll: (tabId, scrollTop) => {
|
||||
set({
|
||||
tabs: get().tabs.map((t) => (t.id === tabId ? { ...t, scrollTop } : t)),
|
||||
});
|
||||
},
|
||||
|
||||
updateTabCursor: (tabId, line, col) => {
|
||||
set({
|
||||
tabs: get().tabs.map((t) =>
|
||||
t.id === tabId ? { ...t, cursorLine: line, cursorCol: col } : t
|
||||
),
|
||||
});
|
||||
},
|
||||
|
||||
setMarkdownViewMode: (mode) => set({ markdownViewMode: mode }),
|
||||
|
||||
setMobileBrowserVisible: (visible) => set({ mobileBrowserVisible: visible }),
|
||||
|
||||
setTabSize: (size) => set({ tabSize: size }),
|
||||
setWordWrap: (wrap) => set({ wordWrap: wrap }),
|
||||
setFontSize: (size) => set({ fontSize: size }),
|
||||
|
||||
setGitStatusMap: (map) => set({ gitStatusMap: map }),
|
||||
setEnhancedGitStatusMap: (map) => set({ enhancedGitStatusMap: map }),
|
||||
setGitBranch: (branch) => set({ gitBranch: branch }),
|
||||
setActiveFileGitDetails: (details) => set({ activeFileGitDetails: details }),
|
||||
|
||||
setDragState: (state) => set({ dragState: state }),
|
||||
setSelectedPaths: (paths) => set({ selectedPaths: paths }),
|
||||
toggleSelectedPath: (path) => {
|
||||
const { selectedPaths } = get();
|
||||
const next = new Set(selectedPaths);
|
||||
if (next.has(path)) {
|
||||
next.delete(path);
|
||||
} else {
|
||||
next.add(path);
|
||||
}
|
||||
set({ selectedPaths: next });
|
||||
},
|
||||
clearSelectedPaths: () => set({ selectedPaths: new Set() }),
|
||||
|
||||
reset: () => set(initialState),
|
||||
}),
|
||||
{
|
||||
name: STORE_NAME,
|
||||
version: 1,
|
||||
// Only persist tab session state, not transient data (git status, file tree, drag state)
|
||||
partialize: (state) =>
|
||||
({
|
||||
tabs: state.tabs,
|
||||
activeTabId: state.activeTabId,
|
||||
expandedFolders: state.expandedFolders,
|
||||
markdownViewMode: state.markdownViewMode,
|
||||
}) as unknown as FileEditorState,
|
||||
// Custom storage adapter to handle Set<string> serialization
|
||||
storage: {
|
||||
getItem: (name: string): StorageValue<FileEditorState> | null => {
|
||||
const raw = localStorage.getItem(name);
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as StorageValue<PersistedFileEditorState>;
|
||||
if (!parsed?.state) return null;
|
||||
// Convert arrays back to Sets
|
||||
return {
|
||||
...parsed,
|
||||
state: {
|
||||
...parsed.state,
|
||||
expandedFolders: new Set(parsed.state.expandedFolders ?? []),
|
||||
},
|
||||
} as unknown as StorageValue<FileEditorState>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
setItem: (name: string, value: StorageValue<FileEditorState>): void => {
|
||||
try {
|
||||
const state = value.state as unknown as FileEditorState;
|
||||
// Convert Sets to arrays for JSON serialization
|
||||
const serializable: StorageValue<PersistedFileEditorState> = {
|
||||
...value,
|
||||
state: {
|
||||
tabs: state.tabs ?? [],
|
||||
activeTabId: state.activeTabId ?? null,
|
||||
expandedFolders: Array.from(state.expandedFolders ?? []),
|
||||
markdownViewMode: state.markdownViewMode ?? 'split',
|
||||
},
|
||||
};
|
||||
localStorage.setItem(name, JSON.stringify(serializable));
|
||||
} catch {
|
||||
// localStorage might be full or disabled
|
||||
}
|
||||
},
|
||||
removeItem: (name: string): void => {
|
||||
try {
|
||||
localStorage.removeItem(name);
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
},
|
||||
},
|
||||
migrate: (persistedState: unknown, version: number) => {
|
||||
const state = persistedState as Record<string, unknown>;
|
||||
if (version < 1) {
|
||||
// Initial migration: ensure all fields exist
|
||||
state.tabs = state.tabs ?? [];
|
||||
state.activeTabId = state.activeTabId ?? null;
|
||||
state.expandedFolders = state.expandedFolders ?? new Set<string>();
|
||||
state.markdownViewMode = state.markdownViewMode ?? 'split';
|
||||
}
|
||||
return state as unknown as FileEditorState;
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -10,6 +10,7 @@ import { SettingsNavigation } from './settings-view/components/settings-navigati
|
||||
import { ApiKeysSection } from './settings-view/api-keys/api-keys-section';
|
||||
import { ModelDefaultsSection } from './settings-view/model-defaults';
|
||||
import { AppearanceSection } from './settings-view/appearance/appearance-section';
|
||||
import { EditorSection } from './settings-view/editor';
|
||||
import { TerminalSection } from './settings-view/terminal/terminal-section';
|
||||
import { AudioSection } from './settings-view/audio/audio-section';
|
||||
import { KeyboardShortcutsSection } from './settings-view/keyboard-shortcuts/keyboard-shortcuts-section';
|
||||
@@ -148,6 +149,8 @@ export function SettingsView() {
|
||||
onThemeChange={(newTheme) => setTheme(newTheme as typeof theme)}
|
||||
/>
|
||||
);
|
||||
case 'editor':
|
||||
return <EditorSection />;
|
||||
case 'terminal':
|
||||
return <TerminalSection />;
|
||||
case 'keyboard':
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
GitBranch,
|
||||
Code2,
|
||||
Webhook,
|
||||
FileCode2,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
AnthropicIcon,
|
||||
@@ -69,6 +70,7 @@ export const GLOBAL_NAV_GROUPS: NavigationGroup[] = [
|
||||
label: 'Interface',
|
||||
items: [
|
||||
{ id: 'appearance', label: 'Appearance', icon: Palette },
|
||||
{ id: 'editor', label: 'File Editor', icon: FileCode2 },
|
||||
{ id: 'terminal', label: 'Terminal', icon: SquareTerminal },
|
||||
{ id: 'keyboard', label: 'Keyboard Shortcuts', icon: Settings2 },
|
||||
{ id: 'audio', label: 'Audio', icon: Volume2 },
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { FileCode2 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { UI_MONO_FONT_OPTIONS, DEFAULT_FONT_VALUE } from '@/config/ui-font-options';
|
||||
|
||||
/**
|
||||
* Editor font options - reuses UI_MONO_FONT_OPTIONS with editor-specific default label
|
||||
*
|
||||
* The 'default' value means "use the default editor font" (Geist Mono / theme default)
|
||||
*/
|
||||
const EDITOR_FONT_OPTIONS = UI_MONO_FONT_OPTIONS.map((option) => {
|
||||
if (option.value === DEFAULT_FONT_VALUE) {
|
||||
return { value: option.value, label: 'Default (Geist Mono)' };
|
||||
}
|
||||
return option;
|
||||
});
|
||||
|
||||
export function EditorSection() {
|
||||
const {
|
||||
editorFontSize,
|
||||
editorFontFamily,
|
||||
editorAutoSave,
|
||||
setEditorFontSize,
|
||||
setEditorFontFamily,
|
||||
setEditorAutoSave,
|
||||
} = useAppStore();
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-2xl overflow-hidden',
|
||||
'border border-border/50',
|
||||
'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
|
||||
'shadow-sm shadow-black/5'
|
||||
)}
|
||||
>
|
||||
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-blue-500/20 to-blue-600/10 flex items-center justify-center border border-blue-500/20">
|
||||
<FileCode2 className="w-5 h-5 text-blue-500" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-foreground tracking-tight">File Editor</h2>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground/80 ml-12">
|
||||
Customize the appearance of the built-in file editor.
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Font Family */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-foreground font-medium">Font Family</Label>
|
||||
<Select
|
||||
value={editorFontFamily || DEFAULT_FONT_VALUE}
|
||||
onValueChange={(value) => {
|
||||
setEditorFontFamily(value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Default (Geist Mono)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{EDITOR_FONT_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: option.value === DEFAULT_FONT_VALUE ? undefined : option.value,
|
||||
}}
|
||||
>
|
||||
{option.label}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Font Size */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-foreground font-medium">Font Size</Label>
|
||||
<span className="text-sm text-muted-foreground">{editorFontSize}px</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[editorFontSize]}
|
||||
min={8}
|
||||
max={32}
|
||||
step={1}
|
||||
onValueChange={([value]) => setEditorFontSize(value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Auto Save */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label className="text-foreground font-medium">Auto Save</Label>
|
||||
<p className="text-xs text-muted-foreground/80 mt-0.5">
|
||||
Automatically save files after changes or when switching tabs
|
||||
</p>
|
||||
</div>
|
||||
<Switch checked={editorAutoSave} onCheckedChange={setEditorAutoSave} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { EditorSection } from './editor-section';
|
||||
@@ -14,6 +14,7 @@ export type SettingsViewId =
|
||||
| 'prompts'
|
||||
| 'model-defaults'
|
||||
| 'appearance'
|
||||
| 'editor'
|
||||
| 'terminal'
|
||||
| 'keyboard'
|
||||
| 'audio'
|
||||
|
||||
@@ -758,6 +758,11 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void {
|
||||
worktreePanelCollapsed: settings.worktreePanelCollapsed ?? false,
|
||||
lastProjectDir: settings.lastProjectDir ?? '',
|
||||
recentFolders: settings.recentFolders ?? [],
|
||||
// File editor settings
|
||||
editorFontSize: settings.editorFontSize ?? 13,
|
||||
editorFontFamily: settings.editorFontFamily ?? 'default',
|
||||
editorAutoSave: settings.editorAutoSave ?? false,
|
||||
editorAutoSaveDelay: settings.editorAutoSaveDelay ?? 1000,
|
||||
// Terminal font (nested in terminalState)
|
||||
...(settings.terminalFontFamily && {
|
||||
terminalState: {
|
||||
@@ -848,6 +853,10 @@ function buildSettingsUpdateFromStore(): Record<string, unknown> {
|
||||
worktreePanelCollapsed: state.worktreePanelCollapsed,
|
||||
lastProjectDir: state.lastProjectDir,
|
||||
recentFolders: state.recentFolders,
|
||||
editorFontSize: state.editorFontSize,
|
||||
editorFontFamily: state.editorFontFamily,
|
||||
editorAutoSave: state.editorAutoSave,
|
||||
editorAutoSaveDelay: state.editorAutoSaveDelay,
|
||||
terminalFontFamily: state.terminalState.fontFamily,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -85,6 +85,10 @@ const SETTINGS_FIELDS_TO_SYNC = [
|
||||
'keyboardShortcuts',
|
||||
'mcpServers',
|
||||
'defaultEditorCommand',
|
||||
'editorFontSize',
|
||||
'editorFontFamily',
|
||||
'editorAutoSave',
|
||||
'editorAutoSaveDelay',
|
||||
'defaultTerminalId',
|
||||
'promptCustomization',
|
||||
'eventHooks',
|
||||
@@ -751,6 +755,10 @@ export async function refreshSettingsFromServer(): Promise<boolean> {
|
||||
},
|
||||
mcpServers: serverSettings.mcpServers,
|
||||
defaultEditorCommand: serverSettings.defaultEditorCommand ?? null,
|
||||
editorFontSize: serverSettings.editorFontSize ?? 13,
|
||||
editorFontFamily: serverSettings.editorFontFamily ?? 'default',
|
||||
editorAutoSave: serverSettings.editorAutoSave ?? false,
|
||||
editorAutoSaveDelay: serverSettings.editorAutoSaveDelay ?? 1000,
|
||||
defaultTerminalId: serverSettings.defaultTerminalId ?? null,
|
||||
promptCustomization: serverSettings.promptCustomization ?? {},
|
||||
claudeApiProfiles: serverSettings.claudeApiProfiles ?? [],
|
||||
|
||||
@@ -647,6 +647,17 @@ export interface ElectronAPI {
|
||||
stat: (filePath: string) => Promise<StatResult>;
|
||||
deleteFile: (filePath: string) => Promise<WriteResult>;
|
||||
trashItem?: (filePath: string) => Promise<WriteResult>;
|
||||
copyItem?: (
|
||||
sourcePath: string,
|
||||
destinationPath: string,
|
||||
overwrite?: boolean
|
||||
) => Promise<WriteResult & { exists?: boolean }>;
|
||||
moveItem?: (
|
||||
sourcePath: string,
|
||||
destinationPath: string,
|
||||
overwrite?: boolean
|
||||
) => Promise<WriteResult & { exists?: boolean }>;
|
||||
downloadItem?: (filePath: string) => Promise<void>;
|
||||
getPath: (name: string) => Promise<string>;
|
||||
openInEditor?: (
|
||||
filePath: string,
|
||||
@@ -2856,6 +2867,47 @@ function createMockGitAPI(): GitAPI {
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
getDetails: async (projectPath: string, filePath?: string) => {
|
||||
console.log('[Mock] Git details:', { projectPath, filePath });
|
||||
return {
|
||||
success: true,
|
||||
details: {
|
||||
branch: 'main',
|
||||
lastCommitHash: 'abc1234567890',
|
||||
lastCommitMessage: 'Initial commit',
|
||||
lastCommitAuthor: 'Developer',
|
||||
lastCommitTimestamp: new Date().toISOString(),
|
||||
linesAdded: 5,
|
||||
linesRemoved: 2,
|
||||
isConflicted: false,
|
||||
isStaged: false,
|
||||
isUnstaged: true,
|
||||
statusLabel: 'Modified',
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
getEnhancedStatus: async (projectPath: string) => {
|
||||
console.log('[Mock] Git enhanced status:', { projectPath });
|
||||
return {
|
||||
success: true,
|
||||
branch: 'main',
|
||||
files: [
|
||||
{
|
||||
path: 'src/feature.ts',
|
||||
indexStatus: ' ',
|
||||
workTreeStatus: 'M',
|
||||
isConflicted: false,
|
||||
isStaged: false,
|
||||
isUnstaged: true,
|
||||
linesAdded: 10,
|
||||
linesRemoved: 3,
|
||||
statusLabel: 'Modified',
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1251,6 +1251,69 @@ export class HttpApiClient implements ElectronAPI {
|
||||
return this.deleteFile(filePath);
|
||||
}
|
||||
|
||||
async copyItem(
|
||||
sourcePath: string,
|
||||
destinationPath: string,
|
||||
overwrite?: boolean
|
||||
): Promise<WriteResult & { exists?: boolean }> {
|
||||
return this.post('/api/fs/copy', { sourcePath, destinationPath, overwrite });
|
||||
}
|
||||
|
||||
async moveItem(
|
||||
sourcePath: string,
|
||||
destinationPath: string,
|
||||
overwrite?: boolean
|
||||
): Promise<WriteResult & { exists?: boolean }> {
|
||||
return this.post('/api/fs/move', { sourcePath, destinationPath, overwrite });
|
||||
}
|
||||
|
||||
async downloadItem(filePath: string): Promise<void> {
|
||||
const serverUrl = getServerUrl();
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
const apiKey = getApiKey();
|
||||
if (apiKey) {
|
||||
headers['X-API-Key'] = apiKey;
|
||||
}
|
||||
const token = getSessionToken();
|
||||
if (token) {
|
||||
headers['X-Session-Token'] = token;
|
||||
}
|
||||
|
||||
const response = await fetch(`${serverUrl}/api/fs/download`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ filePath }),
|
||||
});
|
||||
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
handleUnauthorized();
|
||||
throw new Error('Unauthorized');
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: 'Download failed' }));
|
||||
throw new Error(error.error || `Download failed with status ${response.status}`);
|
||||
}
|
||||
|
||||
// Create download from response blob
|
||||
const blob = await response.blob();
|
||||
const contentDisposition = response.headers.get('Content-Disposition');
|
||||
const fileNameMatch = contentDisposition?.match(/filename="(.+)"/);
|
||||
const fileName = fileNameMatch ? fileNameMatch[1] : filePath.split('/').pop() || 'download';
|
||||
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = fileName;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
async getPath(name: string): Promise<string> {
|
||||
// Server provides data directory
|
||||
if (name === 'userData') {
|
||||
@@ -2311,6 +2374,10 @@ export class HttpApiClient implements ElectronAPI {
|
||||
this.post('/api/git/file-diff', { projectPath, filePath }),
|
||||
stageFiles: (projectPath: string, files: string[], operation: 'stage' | 'unstage') =>
|
||||
this.post('/api/git/stage-files', { projectPath, files, operation }),
|
||||
getDetails: (projectPath: string, filePath?: string) =>
|
||||
this.post('/api/git/details', { projectPath, filePath }),
|
||||
getEnhancedStatus: (projectPath: string) =>
|
||||
this.post('/api/git/enhanced-status', { projectPath }),
|
||||
};
|
||||
|
||||
// Spec Regeneration API
|
||||
|
||||
11
apps/ui/src/routes/file-editor.lazy.tsx
Normal file
11
apps/ui/src/routes/file-editor.lazy.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { createLazyFileRoute, useSearch } from '@tanstack/react-router';
|
||||
import { FileEditorView } from '@/components/views/file-editor-view/file-editor-view';
|
||||
|
||||
export const Route = createLazyFileRoute('/file-editor')({
|
||||
component: RouteComponent,
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
const { path } = useSearch({ from: '/file-editor' });
|
||||
return <FileEditorView initialPath={path} />;
|
||||
}
|
||||
11
apps/ui/src/routes/file-editor.tsx
Normal file
11
apps/ui/src/routes/file-editor.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { z } from 'zod';
|
||||
|
||||
const fileEditorSearchSchema = z.object({
|
||||
path: z.string().optional(),
|
||||
});
|
||||
|
||||
// Component is lazy-loaded via file-editor.lazy.tsx for code splitting
|
||||
export const Route = createFileRoute('/file-editor')({
|
||||
validateSearch: fileEditorSearchSchema,
|
||||
});
|
||||
@@ -343,6 +343,10 @@ const initialState: AppState = {
|
||||
skipSandboxWarning: false,
|
||||
mcpServers: [],
|
||||
defaultEditorCommand: null,
|
||||
editorFontSize: 13,
|
||||
editorFontFamily: 'default',
|
||||
editorAutoSave: false,
|
||||
editorAutoSaveDelay: 1000,
|
||||
defaultTerminalId: null,
|
||||
enableSkills: true,
|
||||
skillsSources: ['user', 'project'] as Array<'user' | 'project'>,
|
||||
@@ -1389,6 +1393,12 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
||||
// Editor Configuration actions
|
||||
setDefaultEditorCommand: (command) => set({ defaultEditorCommand: command }),
|
||||
|
||||
// File Editor Settings actions
|
||||
setEditorFontSize: (size) => set({ editorFontSize: size }),
|
||||
setEditorFontFamily: (fontFamily) => set({ editorFontFamily: fontFamily }),
|
||||
setEditorAutoSave: (enabled) => set({ editorAutoSave: enabled }),
|
||||
setEditorAutoSaveDelay: (delay) => set({ editorAutoSaveDelay: delay }),
|
||||
|
||||
// Terminal Configuration actions
|
||||
setDefaultTerminalId: (terminalId) => set({ defaultTerminalId: terminalId }),
|
||||
|
||||
|
||||
@@ -237,6 +237,12 @@ export interface AppState {
|
||||
// Editor Configuration
|
||||
defaultEditorCommand: string | null; // Default editor for "Open In" action
|
||||
|
||||
// File Editor Settings
|
||||
editorFontSize: number; // Font size for file editor (default: 13)
|
||||
editorFontFamily: string; // Font family for file editor (default: 'default' = use theme mono font)
|
||||
editorAutoSave: boolean; // Enable auto-save for file editor (default: false)
|
||||
editorAutoSaveDelay: number; // Auto-save delay in milliseconds (default: 1000)
|
||||
|
||||
// Terminal Configuration
|
||||
defaultTerminalId: string | null; // Default external terminal for "Open In Terminal" action (null = integrated)
|
||||
|
||||
@@ -611,6 +617,12 @@ export interface AppActions {
|
||||
// Editor Configuration actions
|
||||
setDefaultEditorCommand: (command: string | null) => void;
|
||||
|
||||
// File Editor Settings actions
|
||||
setEditorFontSize: (size: number) => void;
|
||||
setEditorFontFamily: (fontFamily: string) => void;
|
||||
setEditorAutoSave: (enabled: boolean) => void;
|
||||
setEditorAutoSaveDelay: (delay: number) => void;
|
||||
|
||||
// Terminal Configuration actions
|
||||
setDefaultTerminalId: (terminalId: string | null) => void;
|
||||
|
||||
|
||||
@@ -944,6 +944,19 @@
|
||||
animation: accordion-up 0.2s ease-out forwards;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
CODE EDITOR - MOBILE RESPONSIVE STYLES
|
||||
Reduce line number gutter width on mobile
|
||||
======================================== */
|
||||
|
||||
/* On small screens (mobile), reduce line number gutter width to save horizontal space */
|
||||
@media (max-width: 640px) {
|
||||
.cm-lineNumbers .cm-gutterElement {
|
||||
min-width: 1.75rem !important;
|
||||
padding-right: 0.25rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Terminal scrollbar theming */
|
||||
.xterm-viewport::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
|
||||
67
apps/ui/src/types/electron.d.ts
vendored
67
apps/ui/src/types/electron.d.ts
vendored
@@ -9,6 +9,7 @@ import type {
|
||||
GeminiUsageResponse,
|
||||
} from '@/store/app-store';
|
||||
import type { ParsedTask, FeatureStatusWithPipeline, MergeStateInfo } from '@automaker/types';
|
||||
export type { MergeStateInfo } from '@automaker/types';
|
||||
|
||||
export interface ImageAttachment {
|
||||
id?: string; // Optional - may not be present in messages loaded from server
|
||||
@@ -642,6 +643,27 @@ export interface ElectronAPI {
|
||||
error?: string;
|
||||
}>;
|
||||
|
||||
// Copy, Move, Download APIs
|
||||
copyItem?: (
|
||||
sourcePath: string,
|
||||
destinationPath: string,
|
||||
overwrite?: boolean
|
||||
) => Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
exists?: boolean;
|
||||
}>;
|
||||
moveItem?: (
|
||||
sourcePath: string,
|
||||
destinationPath: string,
|
||||
overwrite?: boolean
|
||||
) => Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
exists?: boolean;
|
||||
}>;
|
||||
downloadItem?: (filePath: string) => Promise<void>;
|
||||
|
||||
// App APIs
|
||||
getPath: (name: string) => Promise<string>;
|
||||
saveImageToTemp: (
|
||||
@@ -1695,6 +1717,45 @@ export interface TestRunnerCompletedEvent {
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface GitFileDetails {
|
||||
branch: string;
|
||||
lastCommitHash: string;
|
||||
lastCommitMessage: string;
|
||||
lastCommitAuthor: string;
|
||||
lastCommitTimestamp: string;
|
||||
linesAdded: number;
|
||||
linesRemoved: number;
|
||||
isConflicted: boolean;
|
||||
isStaged: boolean;
|
||||
isUnstaged: boolean;
|
||||
statusLabel: string;
|
||||
}
|
||||
|
||||
export interface EnhancedFileStatus {
|
||||
path: string;
|
||||
indexStatus: string;
|
||||
workTreeStatus: string;
|
||||
isConflicted: boolean;
|
||||
isStaged: boolean;
|
||||
isUnstaged: boolean;
|
||||
linesAdded: number;
|
||||
linesRemoved: number;
|
||||
statusLabel: string;
|
||||
}
|
||||
|
||||
export interface EnhancedStatusResult {
|
||||
success: boolean;
|
||||
branch?: string;
|
||||
files?: EnhancedFileStatus[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface GitDetailsResult {
|
||||
success: boolean;
|
||||
details?: GitFileDetails;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface GitAPI {
|
||||
// Get diffs for the main project (not a worktree)
|
||||
getDiffs: (projectPath: string) => Promise<FileDiffsResult>;
|
||||
@@ -1715,6 +1776,12 @@ export interface GitAPI {
|
||||
};
|
||||
error?: string;
|
||||
}>;
|
||||
|
||||
// Get detailed git info for a file (branch, last commit, diff stats, conflict status)
|
||||
getDetails: (projectPath: string, filePath?: string) => Promise<GitDetailsResult>;
|
||||
|
||||
// Get enhanced status with per-file diff stats and staged/unstaged differentiation
|
||||
getEnhancedStatus: (projectPath: string) => Promise<EnhancedStatusResult>;
|
||||
}
|
||||
|
||||
// Model definition type
|
||||
|
||||
Reference in New Issue
Block a user