Workspaces layout, automation page
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import rehypeRaw from 'rehype-raw';
|
||||
import type { ChatMessage } from './types';
|
||||
import { ToolActivity } from './ToolActivity';
|
||||
import { QuestionActivity } from './QuestionActivity';
|
||||
|
||||
const FRONTMATTER_RE = /^<frontmatter>([\s\S]*?)<\/frontmatter>\s*/;
|
||||
|
||||
const CollapsibleBlock = ({ label, content }: { label: string; content: string }) => (
|
||||
<div className="flex justify-center">
|
||||
<details className="max-w-[85%] rounded-2xl bg-duck-dark/5 border border-duck-dark/10 px-4 py-2 text-sm">
|
||||
<summary className="text-xs text-duck-dark/40 hover:text-duck-dark/60 cursor-pointer select-none">
|
||||
{label}
|
||||
</summary>
|
||||
<div className="mt-1.5 text-xs text-duck-dark/40 whitespace-pre-wrap border-l-2 border-duck-dark/10 pl-2 max-h-48 overflow-y-auto">
|
||||
{content}
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
);
|
||||
|
||||
type MessageBubbleProps = {
|
||||
message: ChatMessage;
|
||||
onAnswer?: (text: string) => void;
|
||||
};
|
||||
|
||||
export const MessageBubble = ({ message, onAnswer }: MessageBubbleProps) => {
|
||||
switch (message.role) {
|
||||
case 'user': {
|
||||
const match = message.text.match(FRONTMATTER_RE);
|
||||
const metadata = match ? match[1]!.trim() : null;
|
||||
const text = match ? message.text.slice(match[0]!.length) : message.text;
|
||||
return (
|
||||
<>
|
||||
{metadata && <CollapsibleBlock label="Frontmatter" content={metadata} />}
|
||||
<div className="flex justify-end">
|
||||
<div className="max-w-[80%] rounded-2xl rounded-tr-sm bg-duck-yellow/10 border border-duck-yellow/20 px-4 py-2.5 text-sm text-duck-dark">
|
||||
{message.images?.map((img, i) => (
|
||||
<img key={i} src={img.dataUrl} alt={img.filename} className="max-w-full max-h-64 rounded-lg mb-2" />
|
||||
))}
|
||||
<div className="whitespace-pre-wrap">{text}</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
case 'system':
|
||||
return <CollapsibleBlock label="System prompt" content={message.text} />;
|
||||
|
||||
case 'assistant':
|
||||
if (!message.text) return null;
|
||||
return (
|
||||
<div className="flex justify-start">
|
||||
<div className="max-w-[85%] rounded-2xl rounded-tl-sm bg-white/80 border border-duck-dark/10 px-4 py-2.5 text-sm text-duck-dark prose prose-sm max-w-none prose-pre:bg-gray-900 prose-pre:text-green-400 prose-code:text-duck-teal prose-code:before:content-none prose-code:after:content-none">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]}>
|
||||
{message.text}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'tool':
|
||||
if (message.toolName === 'question' && onAnswer) {
|
||||
return <QuestionActivity message={message} onAnswer={onAnswer} />;
|
||||
}
|
||||
return <ToolActivity message={message} />;
|
||||
|
||||
case 'result':
|
||||
return (
|
||||
<div className="flex justify-center py-1">
|
||||
<span className="text-xs text-duck-dark/40">
|
||||
Done · ${message.costUsd.toFixed(3)} · {(message.durationMs / 1000).toFixed(1)}s · {message.numTurns} turn
|
||||
{message.numTurns !== 1 ? 's' : ''}
|
||||
{message.isError ? ' (with errors)' : ''}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'error':
|
||||
return (
|
||||
<div className="flex justify-start">
|
||||
<div className="max-w-[80%] rounded-2xl bg-red-50 border border-red-200 px-4 py-2.5 text-sm text-red-700">
|
||||
{message.text}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
type StreamingBubbleProps = {
|
||||
text: string;
|
||||
};
|
||||
|
||||
export const StreamingBubble = ({ text }: StreamingBubbleProps) => {
|
||||
if (!text) return null;
|
||||
return (
|
||||
<div className="flex justify-start">
|
||||
<div className="max-w-[85%] rounded-2xl rounded-tl-sm bg-white/80 border border-duck-dark/10 px-4 py-2.5 text-sm text-duck-dark prose prose-sm max-w-none prose-pre:bg-gray-900 prose-pre:text-green-400 prose-code:text-duck-teal prose-code:before:content-none prose-code:after:content-none">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]}>
|
||||
{text}
|
||||
</ReactMarkdown>
|
||||
<span className="inline-block w-2 h-4 bg-duck-teal/60 animate-pulse ml-0.5 align-middle" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { RefObject } from 'react';
|
||||
import { ArrowDown } from 'lucide-react';
|
||||
import type { ChatMessage } from './types';
|
||||
import { MessageBubble, StreamingBubble } from './MessageBubble';
|
||||
|
||||
type MessageListProps = {
|
||||
messages: ChatMessage[];
|
||||
streamingText: string;
|
||||
isGenerating: boolean;
|
||||
showJumpToBottom: boolean;
|
||||
onJumpToBottom: () => void;
|
||||
onQuestionAnswer?: (text: string) => void;
|
||||
scrollViewportRef: RefObject<HTMLDivElement | null>;
|
||||
bottomRef: RefObject<HTMLDivElement | null>;
|
||||
};
|
||||
|
||||
export const MessageList = ({
|
||||
messages,
|
||||
streamingText,
|
||||
isGenerating,
|
||||
showJumpToBottom,
|
||||
onJumpToBottom,
|
||||
onQuestionAnswer,
|
||||
scrollViewportRef,
|
||||
bottomRef,
|
||||
}: MessageListProps) => (
|
||||
<div className="flex-1 min-h-0 relative">
|
||||
<div ref={scrollViewportRef} className="h-full overflow-y-auto">
|
||||
<div className="p-4 space-y-3">
|
||||
{messages.length === 0 && !isGenerating && (
|
||||
<div className="flex items-center justify-center h-full min-h-[200px] text-duck-dark/30 text-sm">
|
||||
Send a message to start
|
||||
</div>
|
||||
)}
|
||||
{messages.map((msg, i) => (
|
||||
<MessageBubble key={i} message={msg} onAnswer={onQuestionAnswer} />
|
||||
))}
|
||||
{isGenerating && <StreamingBubble text={streamingText} />}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showJumpToBottom && (
|
||||
<button
|
||||
onClick={onJumpToBottom}
|
||||
className="absolute bottom-2 left-1/2 -translate-x-1/2 bg-duck-teal text-white rounded-full p-1.5 shadow-lg hover:bg-duck-teal/90 transition-colors cursor-pointer"
|
||||
>
|
||||
<ArrowDown className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,164 @@
|
||||
import { useState } from 'react';
|
||||
import { MessageCircleQuestion, Check } from 'lucide-react';
|
||||
import type { ChatMessage } from './types';
|
||||
|
||||
type ToolMessage = Extract<ChatMessage, { role: 'tool' }>;
|
||||
|
||||
type QuestionOption = {
|
||||
label: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
type Question = {
|
||||
question: string;
|
||||
header: string;
|
||||
multiple: boolean;
|
||||
options: QuestionOption[];
|
||||
};
|
||||
|
||||
type QuestionActivityProps = {
|
||||
message: ToolMessage;
|
||||
onAnswer: (text: string) => void;
|
||||
};
|
||||
|
||||
export const QuestionActivity = ({ message, onAnswer }: QuestionActivityProps) => {
|
||||
const [selectedOptions, setSelectedOptions] = useState<Set<string>>(new Set());
|
||||
const [otherText, setOtherText] = useState('');
|
||||
const [answered, setAnswered] = useState(false);
|
||||
const [answeredText, setAnsweredText] = useState('');
|
||||
|
||||
const input = message.toolInput as { questions?: Question[] };
|
||||
const questions = input.questions;
|
||||
if (!questions || questions.length === 0) return null;
|
||||
|
||||
const pending = message.output === undefined;
|
||||
|
||||
const handleSelect = (question: Question, label: string) => {
|
||||
if (answered || !pending) return;
|
||||
|
||||
if (question.multiple) {
|
||||
setSelectedOptions((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(label)) next.delete(label);
|
||||
else next.add(label);
|
||||
return next;
|
||||
});
|
||||
} else {
|
||||
const text = label;
|
||||
setAnswered(true);
|
||||
setAnsweredText(text);
|
||||
onAnswer(text);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmitMultiple = () => {
|
||||
if (selectedOptions.size === 0 || answered || !pending) return;
|
||||
const text = Array.from(selectedOptions).join(', ');
|
||||
setAnswered(true);
|
||||
setAnsweredText(text);
|
||||
onAnswer(text);
|
||||
};
|
||||
|
||||
const handleSubmitOther = () => {
|
||||
const text = otherText.trim();
|
||||
if (!text || answered || !pending) return;
|
||||
setAnswered(true);
|
||||
setAnsweredText(text);
|
||||
onAnswer(text);
|
||||
};
|
||||
|
||||
const isDisabled = answered || !pending;
|
||||
|
||||
return (
|
||||
<div className="my-1 space-y-3">
|
||||
{questions.map((q, qi) => (
|
||||
<div key={qi} className="rounded-xl border border-duck-teal/20 bg-white/90 overflow-hidden">
|
||||
<div className="flex items-center gap-2 px-4 py-2.5 bg-duck-teal/5 border-b border-duck-teal/10">
|
||||
<MessageCircleQuestion className="h-4 w-4 text-duck-teal shrink-0" />
|
||||
<span className="text-xs font-medium text-duck-teal uppercase tracking-wider">{q.header}</span>
|
||||
</div>
|
||||
|
||||
<div className="px-4 py-3 space-y-3">
|
||||
<p className="text-sm text-duck-dark font-medium">{q.question}</p>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
{q.options.map((opt) => {
|
||||
const isSelected = answered
|
||||
? answeredText === opt.label || answeredText.split(', ').includes(opt.label)
|
||||
: selectedOptions.has(opt.label);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={opt.label}
|
||||
onClick={() => handleSelect(q, opt.label)}
|
||||
disabled={isDisabled}
|
||||
className={`w-full text-left px-3 py-2 rounded-lg border text-sm transition-colors ${
|
||||
isSelected
|
||||
? 'border-duck-teal bg-duck-teal/10 text-duck-dark'
|
||||
: isDisabled
|
||||
? 'border-duck-dark/10 bg-duck-dark/5 text-duck-dark/40 cursor-not-allowed'
|
||||
: 'border-duck-dark/15 hover:border-duck-teal/40 hover:bg-duck-teal/5 text-duck-dark cursor-pointer'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{isSelected && <Check className="h-3.5 w-3.5 text-duck-teal shrink-0" />}
|
||||
<div>
|
||||
<span className="font-medium">{opt.label}</span>
|
||||
{opt.description && <span className="text-duck-dark/50 ml-1.5">— {opt.description}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* "Other" free-text option */}
|
||||
{!isDisabled && (
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={otherText}
|
||||
onChange={(ev) => setOtherText(ev.target.value)}
|
||||
onKeyDown={(ev) => {
|
||||
if (ev.key === 'Enter') {
|
||||
ev.preventDefault();
|
||||
handleSubmitOther();
|
||||
}
|
||||
}}
|
||||
placeholder="Other..."
|
||||
className="flex-1 px-3 py-1.5 rounded-lg border border-duck-dark/15 text-sm text-duck-dark placeholder:text-duck-dark/30 focus:outline-none focus:border-duck-teal/40"
|
||||
/>
|
||||
<button
|
||||
onClick={handleSubmitOther}
|
||||
disabled={!otherText.trim()}
|
||||
className="px-3 py-1.5 rounded-lg bg-duck-teal text-white text-sm font-medium disabled:opacity-40 disabled:cursor-not-allowed hover:bg-duck-teal/90 transition-colors cursor-pointer"
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submit button for multi-select */}
|
||||
{q.multiple && !isDisabled && (
|
||||
<button
|
||||
onClick={handleSubmitMultiple}
|
||||
disabled={selectedOptions.size === 0}
|
||||
className="px-4 py-1.5 rounded-lg bg-duck-teal text-white text-sm font-medium disabled:opacity-40 disabled:cursor-not-allowed hover:bg-duck-teal/90 transition-colors cursor-pointer"
|
||||
>
|
||||
Submit ({selectedOptions.size} selected)
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Answered indicator */}
|
||||
{isDisabled && answeredText && (
|
||||
<div className="flex items-center gap-1.5 text-xs text-duck-teal">
|
||||
<Check className="h-3 w-3" />
|
||||
<span>Answered: {answeredText}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,138 @@
|
||||
import { useState } from 'react';
|
||||
import { FileText, Terminal, Pencil, Search, Globe, Wrench, ChevronRight } from 'lucide-react';
|
||||
import type { ChatMessage } from './types';
|
||||
|
||||
type ToolMessage = Extract<ChatMessage, { role: 'tool' }>;
|
||||
|
||||
type ToolActivityProps = {
|
||||
message: ToolMessage;
|
||||
};
|
||||
|
||||
const toolIcons: Record<string, typeof FileText> = {
|
||||
Read: FileText,
|
||||
Edit: Pencil,
|
||||
Write: Pencil,
|
||||
Bash: Terminal,
|
||||
Grep: Search,
|
||||
Glob: Search,
|
||||
WebFetch: Globe,
|
||||
WebSearch: Globe,
|
||||
};
|
||||
|
||||
function getToolSummary(toolName: string, toolInput: Record<string, unknown>): string {
|
||||
switch (toolName) {
|
||||
case 'Read':
|
||||
case 'Edit':
|
||||
case 'Write':
|
||||
return (toolInput.file_path as string) ?? '';
|
||||
case 'Bash':
|
||||
return truncate((toolInput.command as string) ?? '', 80);
|
||||
case 'Grep':
|
||||
case 'Glob':
|
||||
return (toolInput.pattern as string) ?? '';
|
||||
case 'WebFetch':
|
||||
return (toolInput.url as string) ?? '';
|
||||
case 'WebSearch':
|
||||
return (toolInput.query as string) ?? '';
|
||||
default:
|
||||
return (
|
||||
Object.values(toolInput)
|
||||
.find((v) => typeof v === 'string')
|
||||
?.toString() ?? ''
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function truncate(str: string, max: number): string {
|
||||
return str.length > max ? str.slice(0, max) + '...' : str;
|
||||
}
|
||||
|
||||
export const ToolActivity = ({ message }: ToolActivityProps) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const Icon = toolIcons[message.toolName] ?? Wrench;
|
||||
const summary = getToolSummary(message.toolName, message.toolInput);
|
||||
const pending = message.output === undefined;
|
||||
const isError = message.isError === true;
|
||||
|
||||
return (
|
||||
<div className="my-1">
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
className="flex items-center gap-2 w-full text-left px-3 py-1.5 rounded-md hover:bg-duck-dark/5 transition-colors cursor-pointer text-sm"
|
||||
>
|
||||
<ChevronRight className={`h-3 w-3 shrink-0 transition-transform ${open ? 'rotate-90' : ''}`} />
|
||||
<Icon className="h-4 w-4 shrink-0 text-duck-teal" />
|
||||
<span className="font-medium text-duck-dark/80">{message.toolName}</span>
|
||||
<span className="text-duck-dark/50 truncate flex-1 font-mono text-xs">{summary}</span>
|
||||
<span className="shrink-0">
|
||||
{pending && <span className="inline-block h-2 w-2 rounded-full bg-amber-400 animate-pulse" />}
|
||||
{!pending && !isError && <span className="text-green-600 text-xs">done</span>}
|
||||
{!pending && isError && <span className="text-red-600 text-xs">error</span>}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="ml-7 mt-1 space-y-2 text-xs">
|
||||
<div className="rounded-md bg-duck-dark/5 p-2 overflow-x-auto">
|
||||
<div className="text-duck-dark/50 text-[10px] uppercase tracking-wider mb-1">Input</div>
|
||||
{message.toolName === 'Bash' ? (
|
||||
<pre className="bg-gray-900 text-green-400 p-2 rounded font-mono whitespace-pre-wrap break-all">
|
||||
{(message.toolInput.command as string) ?? JSON.stringify(message.toolInput, null, 2)}
|
||||
</pre>
|
||||
) : (
|
||||
<pre className="font-mono whitespace-pre-wrap break-all text-duck-dark/70">
|
||||
{Object.entries(message.toolInput)
|
||||
.map(([k, v]) => `${k}: ${typeof v === 'string' ? v : JSON.stringify(v)}`)
|
||||
.join('\n')}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{message.output !== undefined && (
|
||||
<div className="rounded-md bg-duck-dark/5 p-2 overflow-x-auto">
|
||||
<div className="text-duck-dark/50 text-[10px] uppercase tracking-wider mb-1">Output</div>
|
||||
<ToolOutput toolName={message.toolName} output={message.output} isError={isError} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type ToolOutputProps = {
|
||||
toolName: string;
|
||||
output: string;
|
||||
isError: boolean;
|
||||
};
|
||||
|
||||
const ToolOutput = ({ toolName, output, isError }: ToolOutputProps) => {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const maxLines = 20;
|
||||
const lines = output.split('\n');
|
||||
const needsTruncation = lines.length > maxLines;
|
||||
const displayText = expanded ? output : lines.slice(0, maxLines).join('\n');
|
||||
|
||||
const isBash = toolName === 'Bash';
|
||||
|
||||
return (
|
||||
<>
|
||||
<pre
|
||||
className={`font-mono whitespace-pre-wrap break-all p-2 rounded ${
|
||||
isBash ? 'bg-gray-900 text-green-400' : isError ? 'bg-red-50 text-red-700' : 'text-duck-dark/70'
|
||||
}`}
|
||||
>
|
||||
{displayText}
|
||||
</pre>
|
||||
{needsTruncation && (
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="text-duck-teal hover:underline text-[11px] mt-1 cursor-pointer"
|
||||
>
|
||||
{expanded ? 'Show less' : `Show more (${lines.length - maxLines} more lines)`}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
export { MessageList } from './MessageList';
|
||||
export { MessageBubble, StreamingBubble } from './MessageBubble';
|
||||
export { ToolActivity } from './ToolActivity';
|
||||
export { QuestionActivity } from './QuestionActivity';
|
||||
export type { ChatMessage, SessionEntry, ServerMessage, TaskInfo } from './types';
|
||||
@@ -0,0 +1,40 @@
|
||||
export type SessionEntry = {
|
||||
id: string;
|
||||
title: string;
|
||||
createdAt: number;
|
||||
provider: 'claude' | 'opencode';
|
||||
model?: string | null;
|
||||
};
|
||||
|
||||
export type ChatMessage =
|
||||
| { role: 'user'; text: string; images?: { filename: string; dataUrl: string }[] }
|
||||
| { role: 'assistant'; text: string }
|
||||
| { role: 'system'; text: string }
|
||||
| {
|
||||
role: 'tool';
|
||||
toolName: string;
|
||||
toolInput: Record<string, unknown>;
|
||||
toolUseId: string;
|
||||
output?: string;
|
||||
isError?: boolean;
|
||||
}
|
||||
| { role: 'result'; costUsd: number; durationMs: number; numTurns: number; isError: boolean }
|
||||
| { role: 'error'; text: string };
|
||||
|
||||
export type TaskInfo = {
|
||||
taskName: string;
|
||||
taskDirName: string;
|
||||
entryName: string;
|
||||
entryType: 'file' | 'directory';
|
||||
};
|
||||
|
||||
export type ServerMessage =
|
||||
| { type: 'session:init'; sessionId: string; model: string }
|
||||
| { type: 'system:prompt'; text: string }
|
||||
| { type: 'assistant:text'; text: string }
|
||||
| { type: 'assistant:partial'; text: string }
|
||||
| { type: 'tool:use'; toolName: string; toolInput: Record<string, unknown>; toolUseId: string }
|
||||
| { type: 'tool:result'; toolUseId: string; output: string; isError: boolean }
|
||||
| { type: 'result'; costUsd: number; durationMs: number; numTurns: number; isError: boolean }
|
||||
| { type: 'error'; message: string }
|
||||
| { type: 'stopped' };
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Link } from 'react-router';
|
||||
import { ArrowLeft, Archive, Trash2, Maximize2, Minimize2 } from 'lucide-react';
|
||||
|
||||
type SessionBarProps = {
|
||||
listPath: string;
|
||||
provider: 'claude' | 'opencode';
|
||||
sessionTitle: string | undefined;
|
||||
isConnected: boolean;
|
||||
isGenerating: boolean;
|
||||
fullscreen: boolean;
|
||||
onArchive: (() => void) | undefined;
|
||||
onDelete: () => void;
|
||||
onToggleFullscreen: () => void;
|
||||
};
|
||||
|
||||
export const SessionBar = ({
|
||||
listPath,
|
||||
provider,
|
||||
sessionTitle,
|
||||
isConnected,
|
||||
isGenerating,
|
||||
fullscreen,
|
||||
onArchive,
|
||||
onDelete,
|
||||
onToggleFullscreen,
|
||||
}: SessionBarProps) => (
|
||||
<div className="shrink-0 flex items-center px-4 py-2 border-b border-duck-dark/10 bg-white/60">
|
||||
<div className="flex items-center gap-1">
|
||||
<Link to={listPath} className="p-1 text-duck-dark/40 hover:text-duck-dark transition-colors">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Link>
|
||||
{provider === 'claude' && onArchive && (
|
||||
<button
|
||||
onClick={onArchive}
|
||||
className="p-1 text-duck-dark/40 hover:text-duck-teal transition-colors cursor-pointer"
|
||||
>
|
||||
<Archive className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
<button onClick={onDelete} className="p-1 text-duck-dark/40 hover:text-red-500 transition-colors cursor-pointer">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 text-center text-sm font-medium text-duck-dark/70 truncate px-3">
|
||||
{sessionTitle ?? 'New chat'}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-duck-dark/50">
|
||||
{!isConnected ? (
|
||||
<span className="inline-block h-2 w-2 rounded-full bg-red-500" />
|
||||
) : isGenerating ? (
|
||||
<span className="inline-block h-2 w-2 rounded-full bg-duck-orange animate-pulse" />
|
||||
) : (
|
||||
<span className="inline-block h-2 w-2 rounded-full bg-green-500" />
|
||||
)}
|
||||
<span>{!isConnected ? 'Disconnected' : isGenerating ? 'Working...' : ''}</span>
|
||||
<button
|
||||
onClick={onToggleFullscreen}
|
||||
className="p-1 text-duck-dark/40 hover:text-duck-dark transition-colors cursor-pointer"
|
||||
>
|
||||
{fullscreen ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
export { SessionBar } from './SessionBar';
|
||||
@@ -0,0 +1,134 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import Editor, { type OnMount } from '@monaco-editor/react';
|
||||
import type { editor as MonacoEditor } from 'monaco-editor';
|
||||
import { toast } from 'sonner';
|
||||
import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from '@/components/ui/resizable';
|
||||
import { useFiles } from 'apps/FileBrowser';
|
||||
import { FileTree } from './FileTree';
|
||||
import { EditorTabs } from './EditorTabs';
|
||||
import { useEditorState } from './useEditorState';
|
||||
import { getLanguage } from './language-map';
|
||||
|
||||
type CodeEditorViewProps = {
|
||||
className?: string;
|
||||
theme?: string;
|
||||
root?: string;
|
||||
initialPath?: string;
|
||||
};
|
||||
|
||||
export const CodeEditorView = ({ className, theme = 'vs-dark', root = 'home', initialPath }: CodeEditorViewProps) => {
|
||||
const { files, activePath, setActivePath, openFile, closeFile, setContent, markSaved, getActiveFile } =
|
||||
useEditorState();
|
||||
const { readFile, writeFile } = useFiles(root);
|
||||
const editorRef = useRef<MonacoEditor.IStandaloneCodeEditor | null>(null);
|
||||
|
||||
const activeFile = getActiveFile();
|
||||
|
||||
const handleOpenFile = useCallback(
|
||||
async (path: string, name: string) => {
|
||||
const existing = files.find((f) => f.path === path);
|
||||
if (existing) {
|
||||
setActivePath(path);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await readFile(path);
|
||||
openFile(path, name, res.content);
|
||||
} catch {
|
||||
toast.error('Failed to read file');
|
||||
}
|
||||
},
|
||||
[files, setActivePath, readFile, openFile],
|
||||
);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!activeFile || !activeFile.isDirty) return;
|
||||
try {
|
||||
await writeFile(activeFile.path, activeFile.content);
|
||||
markSaved(activeFile.path, activeFile.content);
|
||||
toast.success('File saved');
|
||||
} catch {
|
||||
toast.error('Failed to save file');
|
||||
}
|
||||
}, [activeFile, writeFile, markSaved]);
|
||||
|
||||
const handleEditorMount: OnMount = useCallback(
|
||||
(editor, monaco) => {
|
||||
editorRef.current = editor;
|
||||
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => {
|
||||
handleSave();
|
||||
});
|
||||
},
|
||||
[handleSave],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (ev: KeyboardEvent) => {
|
||||
if ((ev.ctrlKey || ev.metaKey) && ev.key === 's') {
|
||||
ev.preventDefault();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handler);
|
||||
return () => window.removeEventListener('keydown', handler);
|
||||
}, []);
|
||||
|
||||
const bg = theme === 'vs-dark' ? '#1e1e1e' : theme === 'vs' ? '#ffffff' : '#1e1e1e';
|
||||
const borderColor = theme === 'vs-dark' ? '#333' : '#e0e0e0';
|
||||
|
||||
return (
|
||||
<div className={className} style={{ background: bg }}>
|
||||
<ResizablePanelGroup direction="horizontal" className="h-full rounded-lg" style={{ borderColor }}>
|
||||
<ResizablePanel defaultSize={20} minSize={10} maxSize={40}>
|
||||
<div className="h-full flex flex-col" style={{ background: bg }}>
|
||||
<div
|
||||
className="px-3 py-2 text-xs font-semibold uppercase tracking-wider"
|
||||
style={{ color: '#888', borderBottom: `1px solid ${borderColor}` }}
|
||||
>
|
||||
Explorer
|
||||
</div>
|
||||
<FileTree root={root} basePath={initialPath ?? '/'} onOpenFile={handleOpenFile} />
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle />
|
||||
<ResizablePanel defaultSize={80}>
|
||||
<div className="h-full flex flex-col" style={{ background: bg }}>
|
||||
<EditorTabs
|
||||
files={files}
|
||||
activePath={activePath}
|
||||
onSelect={setActivePath}
|
||||
onClose={closeFile}
|
||||
theme={theme}
|
||||
/>
|
||||
{activeFile ? (
|
||||
<Editor
|
||||
key={activeFile.path}
|
||||
theme={theme}
|
||||
language={getLanguage(activeFile.name)}
|
||||
value={activeFile.content}
|
||||
onChange={(value) => setContent(activeFile.path, value ?? '')}
|
||||
onMount={handleEditorMount}
|
||||
options={{
|
||||
automaticLayout: true,
|
||||
minimap: { enabled: true },
|
||||
fontSize: 14,
|
||||
tabSize: 2,
|
||||
wordWrap: 'on',
|
||||
scrollBeyondLastLine: false,
|
||||
renderWhitespace: 'selection',
|
||||
smoothScrolling: true,
|
||||
cursorBlinking: 'smooth',
|
||||
cursorSmoothCaretAnimation: 'on',
|
||||
padding: { top: 8 },
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center text-sm" style={{ color: '#888' }}>
|
||||
Open a file from the explorer to start editing
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useMemo } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import { getIcon } from 'material-file-icons';
|
||||
import type { OpenFile } from './useEditorState';
|
||||
|
||||
type EditorTabsProps = {
|
||||
files: OpenFile[];
|
||||
activePath: string | null;
|
||||
onSelect: (path: string) => void;
|
||||
onClose: (path: string) => void;
|
||||
theme?: string;
|
||||
};
|
||||
|
||||
const FileIcon = ({ name }: { name: string }) => {
|
||||
const svg = useMemo(() => getIcon(name).svg, [name]);
|
||||
return <span className="inline-flex h-4 w-4 shrink-0" dangerouslySetInnerHTML={{ __html: svg }} />;
|
||||
};
|
||||
|
||||
export const EditorTabs = ({ files, activePath, onSelect, onClose, theme }: EditorTabsProps) => {
|
||||
if (files.length === 0) return null;
|
||||
|
||||
const isDark = theme !== 'vs';
|
||||
const borderColor = isDark ? '#333' : '#e0e0e0';
|
||||
const activeBg = isDark ? '#1e1e1e' : '#ffffff';
|
||||
const inactiveBg = isDark ? '#181818' : '#f3f3f3';
|
||||
const activeColor = isDark ? '#ccc' : '#333';
|
||||
const inactiveColor = isDark ? '#888' : '#666';
|
||||
|
||||
return (
|
||||
<div className="flex items-center overflow-x-auto shrink-0 code-editor-scrollable" style={{ borderBottom: `1px solid ${borderColor}` }}>
|
||||
{files.map((file) => {
|
||||
const isActive = file.path === activePath;
|
||||
return (
|
||||
<button
|
||||
key={file.path}
|
||||
onClick={() => onSelect(file.path)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm cursor-pointer whitespace-nowrap transition-colors"
|
||||
style={{
|
||||
background: isActive ? activeBg : inactiveBg,
|
||||
color: isActive ? activeColor : inactiveColor,
|
||||
borderRight: `1px solid ${borderColor}`,
|
||||
}}
|
||||
>
|
||||
<FileIcon name={file.name} />
|
||||
<span>{file.name}</span>
|
||||
{file.isDirty && <span className="w-2 h-2 rounded-full bg-blue-400 shrink-0" />}
|
||||
<span
|
||||
role="button"
|
||||
className="ml-1 p-0.5 rounded transition-colors"
|
||||
style={{ color: inactiveColor }}
|
||||
onClick={(ev) => {
|
||||
ev.stopPropagation();
|
||||
onClose(file.path);
|
||||
}}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,142 @@
|
||||
import { useState, useMemo, useCallback } from 'react';
|
||||
import { ChevronRight, ChevronDown, Folder } from 'lucide-react';
|
||||
import { getIcon } from 'material-file-icons';
|
||||
import { useFiles, type DirEntry } from 'apps/FileBrowser';
|
||||
|
||||
type FileTreeProps = {
|
||||
root: string;
|
||||
basePath: string;
|
||||
onOpenFile: (path: string, name: string) => void;
|
||||
};
|
||||
|
||||
type TreeNodeProps = {
|
||||
entry: DirEntry;
|
||||
parentPath: string;
|
||||
root: string;
|
||||
onOpenFile: (path: string, name: string) => void;
|
||||
depth: number;
|
||||
};
|
||||
|
||||
const FileIcon = ({ name }: { name: string }) => {
|
||||
const svg = useMemo(() => getIcon(name).svg, [name]);
|
||||
return <span className="inline-flex h-4 w-4 shrink-0" dangerouslySetInnerHTML={{ __html: svg }} />;
|
||||
};
|
||||
|
||||
const sortEntries = (entries: DirEntry[]) => {
|
||||
const dirs = entries.filter((e) => e.type === 'directory').sort((a, b) => a.name.localeCompare(b.name));
|
||||
const files = entries.filter((e) => e.type === 'file').sort((a, b) => a.name.localeCompare(b.name));
|
||||
return [...dirs, ...files];
|
||||
};
|
||||
|
||||
const TreeNode = ({ entry, parentPath, root, onOpenFile, depth }: TreeNodeProps) => {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [children, setChildren] = useState<DirEntry[] | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { listDir } = useFiles(root);
|
||||
const isDir = entry.type === 'directory';
|
||||
const fullPath = parentPath === '/' ? `/${entry.name}` : `${parentPath}/${entry.name}`;
|
||||
|
||||
const handleClick = useCallback(async () => {
|
||||
if (!isDir) {
|
||||
onOpenFile(fullPath, entry.name);
|
||||
return;
|
||||
}
|
||||
if (!expanded && children === null) {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await listDir(fullPath);
|
||||
setChildren(sortEntries(res.entries));
|
||||
} catch {
|
||||
setChildren([]);
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
setExpanded((prev) => !prev);
|
||||
}, [isDir, expanded, children, fullPath, entry.name, listDir, onOpenFile]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
onClick={handleClick}
|
||||
className="flex items-center gap-1 w-full px-1 py-0.5 text-sm rounded cursor-pointer transition-colors text-left text-[#ccc] hover:bg-white/5"
|
||||
style={{ paddingLeft: `${depth * 12 + 4}px` }}
|
||||
>
|
||||
{isDir ? (
|
||||
<>
|
||||
{expanded ? (
|
||||
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-[#888]" />
|
||||
) : (
|
||||
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-[#888]" />
|
||||
)}
|
||||
<Folder className="h-4 w-4 shrink-0 text-duck-yellow fill-duck-yellow/30" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="w-3.5 shrink-0" />
|
||||
<FileIcon name={entry.name} />
|
||||
</>
|
||||
)}
|
||||
<span className="truncate">{entry.name}</span>
|
||||
{loading && <span className="text-xs text-[#888] ml-auto">...</span>}
|
||||
</button>
|
||||
{isDir && expanded && children && (
|
||||
<div>
|
||||
{children.map((child) => (
|
||||
<TreeNode
|
||||
key={child.name}
|
||||
entry={child}
|
||||
parentPath={fullPath}
|
||||
root={root}
|
||||
onOpenFile={onOpenFile}
|
||||
depth={depth + 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const FileTree = ({ root, basePath, onOpenFile }: FileTreeProps) => {
|
||||
const { listDir } = useFiles(root);
|
||||
const [entries, setEntries] = useState<DirEntry[] | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const loadRoot = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await listDir(basePath);
|
||||
setEntries(sortEntries(res.entries));
|
||||
} catch {
|
||||
setEntries([]);
|
||||
}
|
||||
setLoading(false);
|
||||
}, [listDir, basePath]);
|
||||
|
||||
if (entries === null && !loading) {
|
||||
loadRoot();
|
||||
}
|
||||
|
||||
if (loading && entries === null) {
|
||||
return <div className="p-2 text-sm text-[#888]">Loading...</div>;
|
||||
}
|
||||
|
||||
if (!entries || entries.length === 0) {
|
||||
return <div className="p-2 text-sm text-[#888]">No files</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="py-1 overflow-y-auto h-full code-editor-scrollable">
|
||||
{entries.map((entry) => (
|
||||
<TreeNode
|
||||
key={entry.name}
|
||||
entry={entry}
|
||||
parentPath={basePath}
|
||||
root={root}
|
||||
onOpenFile={onOpenFile}
|
||||
depth={0}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { CodeEditorView } from './CodeEditor';
|
||||
@@ -0,0 +1,235 @@
|
||||
const extensionToLanguage: Record<string, string> = {
|
||||
// JavaScript / TypeScript
|
||||
ts: 'typescript',
|
||||
tsx: 'typescript',
|
||||
js: 'javascript',
|
||||
jsx: 'javascript',
|
||||
mjs: 'javascript',
|
||||
cjs: 'javascript',
|
||||
|
||||
// Web
|
||||
html: 'html',
|
||||
htm: 'html',
|
||||
css: 'css',
|
||||
scss: 'scss',
|
||||
less: 'less',
|
||||
vue: 'html',
|
||||
svelte: 'html',
|
||||
|
||||
// Data / Config
|
||||
json: 'json',
|
||||
jsonc: 'json',
|
||||
json5: 'json',
|
||||
geojson: 'json',
|
||||
yaml: 'yaml',
|
||||
yml: 'yaml',
|
||||
toml: 'ini',
|
||||
ini: 'ini',
|
||||
cfg: 'ini',
|
||||
conf: 'ini',
|
||||
properties: 'ini',
|
||||
env: 'ini',
|
||||
xml: 'xml',
|
||||
xsl: 'xml',
|
||||
xslt: 'xml',
|
||||
xsd: 'xml',
|
||||
svg: 'xml',
|
||||
plist: 'xml',
|
||||
csproj: 'xml',
|
||||
fsproj: 'xml',
|
||||
vcxproj: 'xml',
|
||||
sln: 'xml',
|
||||
|
||||
// Markdown / Text
|
||||
md: 'markdown',
|
||||
mdx: 'markdown',
|
||||
markdown: 'markdown',
|
||||
txt: 'plaintext',
|
||||
log: 'plaintext',
|
||||
|
||||
// Shell
|
||||
sh: 'shell',
|
||||
bash: 'shell',
|
||||
zsh: 'shell',
|
||||
fish: 'shell',
|
||||
ksh: 'shell',
|
||||
csh: 'shell',
|
||||
ps1: 'powershell',
|
||||
psm1: 'powershell',
|
||||
psd1: 'powershell',
|
||||
bat: 'bat',
|
||||
cmd: 'bat',
|
||||
|
||||
// Python
|
||||
py: 'python',
|
||||
pyw: 'python',
|
||||
pyi: 'python',
|
||||
pyx: 'python',
|
||||
ipynb: 'json',
|
||||
|
||||
// Ruby
|
||||
rb: 'ruby',
|
||||
erb: 'ruby',
|
||||
gemspec: 'ruby',
|
||||
rake: 'ruby',
|
||||
|
||||
// Rust
|
||||
rs: 'rust',
|
||||
|
||||
// Go
|
||||
go: 'go',
|
||||
mod: 'go',
|
||||
|
||||
// Java / JVM
|
||||
java: 'java',
|
||||
kt: 'kotlin',
|
||||
kts: 'kotlin',
|
||||
scala: 'scala',
|
||||
sc: 'scala',
|
||||
groovy: 'groovy',
|
||||
gradle: 'groovy',
|
||||
|
||||
// C / C++ / Objective-C
|
||||
c: 'c',
|
||||
h: 'c',
|
||||
cpp: 'cpp',
|
||||
cc: 'cpp',
|
||||
cxx: 'cpp',
|
||||
hpp: 'cpp',
|
||||
hxx: 'cpp',
|
||||
hh: 'cpp',
|
||||
m: 'objective-c',
|
||||
mm: 'objective-c',
|
||||
|
||||
// C# / F#
|
||||
cs: 'csharp',
|
||||
csx: 'csharp',
|
||||
fs: 'fsharp',
|
||||
fsx: 'fsharp',
|
||||
fsi: 'fsharp',
|
||||
|
||||
// Swift
|
||||
swift: 'swift',
|
||||
|
||||
// Dart
|
||||
dart: 'dart',
|
||||
|
||||
// PHP
|
||||
php: 'php',
|
||||
phtml: 'php',
|
||||
|
||||
// SQL
|
||||
sql: 'sql',
|
||||
mysql: 'sql',
|
||||
pgsql: 'pgsql',
|
||||
|
||||
// Lua
|
||||
lua: 'lua',
|
||||
|
||||
// R
|
||||
r: 'r',
|
||||
rmd: 'markdown',
|
||||
|
||||
// Perl
|
||||
pl: 'perl',
|
||||
pm: 'perl',
|
||||
perl: 'perl',
|
||||
|
||||
// GraphQL
|
||||
graphql: 'graphql',
|
||||
gql: 'graphql',
|
||||
|
||||
// Docker
|
||||
dockerfile: 'dockerfile',
|
||||
|
||||
// Elixir / Erlang
|
||||
ex: 'elixir',
|
||||
exs: 'elixir',
|
||||
erl: 'erlang',
|
||||
hrl: 'erlang',
|
||||
|
||||
// Haskell
|
||||
hs: 'haskell',
|
||||
lhs: 'haskell',
|
||||
|
||||
// Clojure
|
||||
clj: 'clojure',
|
||||
cljs: 'clojure',
|
||||
cljc: 'clojure',
|
||||
edn: 'clojure',
|
||||
|
||||
// Handlebars
|
||||
hbs: 'handlebars',
|
||||
handlebars: 'handlebars',
|
||||
|
||||
// Twig
|
||||
twig: 'twig',
|
||||
|
||||
// Pug
|
||||
pug: 'pug',
|
||||
jade: 'pug',
|
||||
|
||||
// Coffee
|
||||
coffee: 'coffeescript',
|
||||
|
||||
// Diff / Patch
|
||||
diff: 'diff',
|
||||
patch: 'diff',
|
||||
|
||||
// Protocol Buffers
|
||||
proto: 'protobuf',
|
||||
|
||||
// Terraform
|
||||
tf: 'hcl',
|
||||
tfvars: 'hcl',
|
||||
hcl: 'hcl',
|
||||
|
||||
// ABAP
|
||||
abap: 'abap',
|
||||
|
||||
// Apex
|
||||
apex: 'apex',
|
||||
cls: 'apex',
|
||||
trigger: 'apex',
|
||||
|
||||
// Pascal
|
||||
pas: 'pascal',
|
||||
pp: 'pascal',
|
||||
|
||||
// Tcl
|
||||
tcl: 'tcl',
|
||||
|
||||
// Scheme / Lisp
|
||||
scm: 'scheme',
|
||||
ss: 'scheme',
|
||||
rkt: 'scheme',
|
||||
lisp: 'scheme',
|
||||
lsp: 'scheme',
|
||||
el: 'scheme',
|
||||
|
||||
// Misc
|
||||
sol: 'sol',
|
||||
bicep: 'bicep',
|
||||
azcli: 'azcli',
|
||||
redis: 'redis',
|
||||
sb: 'sb',
|
||||
st: 'st',
|
||||
lex: 'lexon',
|
||||
};
|
||||
|
||||
export const getLanguage = (filename: string): string => {
|
||||
const ext = filename.split('.').pop()?.toLowerCase() ?? '';
|
||||
const base = filename.toLowerCase();
|
||||
if (base === 'dockerfile' || base.startsWith('dockerfile.')) return 'dockerfile';
|
||||
if (base === 'makefile' || base === 'gnumakefile') return 'shell';
|
||||
if (base === 'gemfile' || base === 'rakefile' || base === 'vagrantfile') return 'ruby';
|
||||
if (base === 'justfile') return 'shell';
|
||||
if (base === '.gitignore' || base === '.dockerignore' || base === '.editorconfig') return 'ini';
|
||||
if (base === '.prettierrc' || base === '.eslintrc' || base === 'tsconfig.json' || base === 'package.json')
|
||||
return 'json';
|
||||
// Dotfiles that are shell scripts
|
||||
if (/^\.(bash|zsh|sh|ksh|csh)rc$/.test(base)) return 'shell';
|
||||
if (/^\.(bash_|zsh_|sh_)/.test(base)) return 'shell'; // .bash_profile, .zsh_history, etc.
|
||||
if (base === '.profile' || base === '.bash_logout' || base === '.bash_login') return 'shell';
|
||||
return extensionToLanguage[ext] ?? 'plaintext';
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
|
||||
export type OpenFile = {
|
||||
path: string;
|
||||
name: string;
|
||||
content: string;
|
||||
originalContent: string;
|
||||
isDirty: boolean;
|
||||
};
|
||||
|
||||
export const useEditorState = () => {
|
||||
const [files, setFiles] = useState<OpenFile[]>([]);
|
||||
const [activePath, setActivePath] = useState<string | null>(null);
|
||||
|
||||
const openFile = useCallback((path: string, name: string, content: string) => {
|
||||
setFiles((prev) => {
|
||||
const existing = prev.find((f) => f.path === path);
|
||||
if (existing) return prev;
|
||||
return [...prev, { path, name, content, originalContent: content, isDirty: false }];
|
||||
});
|
||||
setActivePath(path);
|
||||
}, []);
|
||||
|
||||
const closeFile = useCallback(
|
||||
(path: string) => {
|
||||
setFiles((prev) => {
|
||||
const next = prev.filter((f) => f.path !== path);
|
||||
if (activePath === path) {
|
||||
const idx = prev.findIndex((f) => f.path === path);
|
||||
const newActive = next[Math.min(idx, next.length - 1)] ?? null;
|
||||
setActivePath(newActive?.path ?? null);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[activePath],
|
||||
);
|
||||
|
||||
const setContent = useCallback((path: string, content: string) => {
|
||||
setFiles((prev) =>
|
||||
prev.map((f) => (f.path === path ? { ...f, content, isDirty: content !== f.originalContent } : f)),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const markSaved = useCallback((path: string, content: string) => {
|
||||
setFiles((prev) =>
|
||||
prev.map((f) => (f.path === path ? { ...f, originalContent: content, content, isDirty: false } : f)),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const getActiveFile = useCallback((): OpenFile | null => {
|
||||
return files.find((f) => f.path === activePath) ?? null;
|
||||
}, [files, activePath]);
|
||||
|
||||
return { files, activePath, setActivePath, openFile, closeFile, setContent, markSaved, getActiveFile };
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
import { ChevronRight, Home } from 'lucide-react';
|
||||
|
||||
type BreadcrumbProps = {
|
||||
path: string;
|
||||
onNavigate: (path: string) => void;
|
||||
};
|
||||
|
||||
export const Breadcrumb = ({ path, onNavigate }: BreadcrumbProps) => {
|
||||
const segments = path.split('/').filter(Boolean);
|
||||
|
||||
return (
|
||||
<nav className="flex items-center gap-1 text-sm flex-wrap">
|
||||
<button
|
||||
onClick={() => onNavigate('/')}
|
||||
className="flex items-center gap-1 text-duck-forest hover:text-duck-teal transition-colors cursor-pointer font-medium"
|
||||
>
|
||||
<Home className="h-4 w-4" />
|
||||
<span>home</span>
|
||||
</button>
|
||||
|
||||
{segments.map((segment, i) => {
|
||||
const segmentPath = '/' + segments.slice(0, i + 1).join('/');
|
||||
const isLast = i === segments.length - 1;
|
||||
|
||||
return (
|
||||
<span key={segmentPath} className="flex items-center gap-1">
|
||||
<ChevronRight className="h-4 w-4 text-duck-dark/40" />
|
||||
{isLast ? (
|
||||
<span className="text-duck-dark font-semibold">{segment}</span>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => onNavigate(segmentPath)}
|
||||
className="text-duck-forest hover:text-duck-teal transition-colors cursor-pointer font-medium"
|
||||
>
|
||||
{segment}
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,202 @@
|
||||
import { useRef, useState, useCallback } from 'react';
|
||||
import { FolderPlus, Upload, FolderUp, Scissors, Copy, ClipboardPaste, Trash2, X, Check } from 'lucide-react';
|
||||
|
||||
type ToolbarProps = {
|
||||
onCreateDir: (name: string) => void;
|
||||
onUpload: (files: FileList) => void;
|
||||
selectionCount: number;
|
||||
hasClipboard: boolean;
|
||||
onCut: () => void;
|
||||
onCopy: () => void;
|
||||
onPaste: () => void;
|
||||
onDeleteSelected: () => void;
|
||||
onClearSelection: () => void;
|
||||
};
|
||||
|
||||
export const Toolbar = ({
|
||||
onCreateDir,
|
||||
onUpload,
|
||||
selectionCount,
|
||||
hasClipboard,
|
||||
onCut,
|
||||
onCopy,
|
||||
onPaste,
|
||||
onDeleteSelected,
|
||||
onClearSelection,
|
||||
}: ToolbarProps) => {
|
||||
const [showInput, setShowInput] = useState(false);
|
||||
const [folderName, setFolderName] = useState('');
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const folderInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const setFolderInputRef = useCallback((input: HTMLInputElement | null) => {
|
||||
folderInputRef.current = input;
|
||||
if (input) input.setAttribute('webkitdirectory', '');
|
||||
}, []);
|
||||
|
||||
const handleCreate = () => {
|
||||
const name = folderName.trim();
|
||||
if (!name) return;
|
||||
onCreateDir(name);
|
||||
setFolderName('');
|
||||
setShowInput(false);
|
||||
};
|
||||
|
||||
const hiddenInputs = (
|
||||
<>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(ev) => {
|
||||
if (ev.target.files?.length) {
|
||||
onUpload(ev.target.files);
|
||||
ev.target.value = '';
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
ref={setFolderInputRef}
|
||||
type="file"
|
||||
className="hidden"
|
||||
onChange={(ev) => {
|
||||
if (ev.target.files?.length) {
|
||||
onUpload(ev.target.files);
|
||||
ev.target.value = '';
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
if (selectionCount > 0) {
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-sm font-medium text-duck-dark/70 mr-1">
|
||||
{selectionCount}
|
||||
<span className="hidden md:inline"> selected</span>
|
||||
</span>
|
||||
<button
|
||||
onClick={onCut}
|
||||
title="Cut"
|
||||
className="p-1.5 rounded-md text-duck-dark/50 hover:bg-duck-dark/5 cursor-pointer transition-colors"
|
||||
>
|
||||
<Scissors className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={onCopy}
|
||||
title="Copy"
|
||||
className="p-1.5 rounded-md text-duck-dark/50 hover:bg-duck-dark/5 cursor-pointer transition-colors"
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</button>
|
||||
{hasClipboard && (
|
||||
<button
|
||||
onClick={onPaste}
|
||||
title="Paste"
|
||||
className="p-1.5 rounded-md text-duck-dark/50 hover:bg-duck-dark/5 cursor-pointer transition-colors"
|
||||
>
|
||||
<ClipboardPaste className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onDeleteSelected}
|
||||
title="Delete"
|
||||
className="p-1.5 rounded-md text-red-500 hover:bg-red-50 cursor-pointer transition-colors"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={onClearSelection}
|
||||
title="Clear selection"
|
||||
className="p-1.5 rounded-md text-duck-dark/50 hover:bg-duck-dark/5 cursor-pointer transition-colors"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
{hiddenInputs}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
{showInput ? (
|
||||
<form
|
||||
onSubmit={(ev) => {
|
||||
ev.preventDefault();
|
||||
handleCreate();
|
||||
}}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<input
|
||||
autoFocus
|
||||
value={folderName}
|
||||
onChange={(ev) => setFolderName(ev.target.value)}
|
||||
placeholder="Folder name"
|
||||
className="h-8 w-40 text-sm rounded-md border border-duck-dark/20 bg-white/60 text-duck-dark placeholder:text-duck-dark/40 outline-none focus:border-duck-teal/50 px-2"
|
||||
onKeyDown={(ev) => {
|
||||
if (ev.key === 'Escape') {
|
||||
setShowInput(false);
|
||||
setFolderName('');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="p-1.5 rounded-md text-duck-teal hover:bg-duck-dark/5 cursor-pointer transition-colors"
|
||||
title="Create"
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowInput(false);
|
||||
setFolderName('');
|
||||
}}
|
||||
className="p-1.5 rounded-md text-duck-dark/50 hover:bg-duck-dark/5 cursor-pointer transition-colors"
|
||||
title="Cancel"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setShowInput(true)}
|
||||
title="New folder"
|
||||
className="p-1.5 rounded-md text-duck-dark/50 hover:bg-duck-dark/5 cursor-pointer transition-colors"
|
||||
>
|
||||
<FolderPlus className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
title="Upload files"
|
||||
className="p-1.5 rounded-md text-duck-dark/50 hover:bg-duck-dark/5 cursor-pointer transition-colors"
|
||||
>
|
||||
<Upload className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => folderInputRef.current?.click()}
|
||||
title="Upload folder"
|
||||
className="p-1.5 rounded-md text-duck-dark/50 hover:bg-duck-dark/5 cursor-pointer transition-colors"
|
||||
>
|
||||
<FolderUp className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
{hasClipboard && (
|
||||
<button
|
||||
onClick={onPaste}
|
||||
title="Paste"
|
||||
className="p-1.5 rounded-md text-duck-dark/50 hover:bg-duck-dark/5 cursor-pointer transition-colors"
|
||||
>
|
||||
<ClipboardPaste className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{hiddenInputs}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export { Breadcrumb } from './Breadcrumb';
|
||||
export { Toolbar } from './Toolbar';
|
||||
export { useFiles, type DirEntry } from './useFiles';
|
||||
export { useTasks, type TaskSummary } from './useTasks';
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useClient, getHeaders } from 'hooks/useClient';
|
||||
import { config } from 'config';
|
||||
|
||||
export type DirEntry = {
|
||||
name: string;
|
||||
path?: string;
|
||||
type: 'file' | 'directory';
|
||||
size: number;
|
||||
modifiedAt: number;
|
||||
};
|
||||
|
||||
type ListDirResponse = {
|
||||
path: string;
|
||||
entries: DirEntry[];
|
||||
reset?: boolean;
|
||||
};
|
||||
|
||||
export const useFiles = (root: string = 'home') => {
|
||||
const client = useClient();
|
||||
const rootParam = root !== 'home' ? `root=${encodeURIComponent(root)}` : '';
|
||||
const withRoot = (url: string) =>
|
||||
rootParam ? (url.includes('?') ? `${url}&${rootParam}` : `${url}?${rootParam}`) : url;
|
||||
|
||||
return {
|
||||
listDir: (path: string) =>
|
||||
client.get<ListDirResponse>(withRoot(`/file-browser/ls?path=${encodeURIComponent(path)}`)),
|
||||
|
||||
createDir: (path: string) => client.post(withRoot('/file-browser/mkdir'), { path }),
|
||||
|
||||
remove: (path: string) => client.delete(withRoot('/file-browser/rm'), { path }),
|
||||
|
||||
rename: (path: string, newName: string) => client.post(withRoot('/file-browser/rename'), { path, newName }),
|
||||
|
||||
readFile: (path: string) =>
|
||||
client.get<{ content: string; size: number }>(withRoot(`/file-browser/read?path=${encodeURIComponent(path)}`)),
|
||||
|
||||
writeFile: (path: string, content: string) =>
|
||||
client.post(withRoot('/file-browser/write'), { path, content }),
|
||||
|
||||
search: (query: string) =>
|
||||
client.get<{ results: DirEntry[] }>(withRoot(`/file-browser/search?q=${encodeURIComponent(query)}`)),
|
||||
|
||||
copy: (sources: string[], destination: string) =>
|
||||
client.post(withRoot('/file-browser/copy'), {
|
||||
items: sources.map((source) => ({
|
||||
source,
|
||||
destination: `${destination}/${source.split('/').pop()}`,
|
||||
})),
|
||||
}),
|
||||
|
||||
move: (sources: string[], destination: string) =>
|
||||
client.post(withRoot('/file-browser/move'), {
|
||||
items: sources.map((source) => ({
|
||||
source,
|
||||
destination: `${destination}/${source.split('/').pop()}`,
|
||||
})),
|
||||
}),
|
||||
|
||||
gitClone: (url: string, path: string) => client.post(withRoot('/file-browser/git-clone'), { url, path }),
|
||||
|
||||
uploadFiles: (path: string, files: FileList | File[], onProgress?: (pct: number) => void): Promise<void> => {
|
||||
const formData = new FormData();
|
||||
for (const file of Array.from(files)) {
|
||||
const name = (file as any).webkitRelativePath || file.name;
|
||||
formData.append('file', file, name);
|
||||
}
|
||||
const authHeaders = getHeaders();
|
||||
const url = withRoot(`${config.API_URL}/file-browser/upload?path=${encodeURIComponent(path)}`);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', url);
|
||||
if (authHeaders['Authorization']) {
|
||||
xhr.setRequestHeader('Authorization', authHeaders['Authorization']);
|
||||
}
|
||||
xhr.upload.onprogress = (e) => {
|
||||
if (e.lengthComputable && onProgress) {
|
||||
onProgress(Math.round((e.loaded / e.total) * 100));
|
||||
}
|
||||
};
|
||||
xhr.onload = () => {
|
||||
if (xhr.status >= 400) reject(new Error(xhr.responseText));
|
||||
else resolve();
|
||||
};
|
||||
xhr.onerror = () => reject(new Error('Upload failed'));
|
||||
xhr.send(formData);
|
||||
});
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
|
||||
type TriggerConfig = { type: 'file'; extensions: string[] } | { type: 'directory' };
|
||||
|
||||
export type TaskSummary = {
|
||||
dirName: string;
|
||||
name: string;
|
||||
description: string;
|
||||
scope: 'user' | 'global';
|
||||
triggers: TriggerConfig[];
|
||||
filePath: string;
|
||||
};
|
||||
|
||||
export const useTasks = () => {
|
||||
const client = useClient();
|
||||
|
||||
const { data: tasks = [] } = useQuery<TaskSummary[]>({
|
||||
queryKey: ['tasks'],
|
||||
queryFn: () => client.get('/tasks'),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const getMatchingTasks = useCallback(
|
||||
(fileName: string, entryType: 'file' | 'directory'): TaskSummary[] => {
|
||||
if (entryType === 'directory') {
|
||||
return tasks.filter((t) => t.triggers.some((tr) => tr.type === 'directory'));
|
||||
}
|
||||
const ext = fileName.split('.').pop()?.toLowerCase();
|
||||
if (!ext) return [];
|
||||
return tasks.filter((t) => t.triggers.some((tr) => tr.type === 'file' && tr.extensions.includes(ext)));
|
||||
},
|
||||
[tasks],
|
||||
);
|
||||
|
||||
return { tasks, getMatchingTasks };
|
||||
};
|
||||
@@ -0,0 +1,188 @@
|
||||
import type { CSSProperties } from 'react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { Terminal as XTerm } from '@xterm/xterm';
|
||||
import { FitAddon } from '@xterm/addon-fit';
|
||||
import '@xterm/xterm/css/xterm.css';
|
||||
import { useMounted } from 'hooks/useMounted';
|
||||
|
||||
type TerminalTheme = {
|
||||
background?: string;
|
||||
foreground?: string;
|
||||
cursor?: string;
|
||||
selectionBackground?: string;
|
||||
};
|
||||
|
||||
export type TerminalViewProps = {
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
wsPath?: string;
|
||||
fontSize?: number;
|
||||
fontFamily?: string;
|
||||
theme?: TerminalTheme;
|
||||
autoFocus?: boolean;
|
||||
onReady?: (term: XTerm) => void;
|
||||
onExit?: () => void;
|
||||
onDisconnect?: () => void;
|
||||
};
|
||||
|
||||
const DEFAULT_THEME: Required<TerminalTheme> = {
|
||||
background: '#1a1a2e',
|
||||
foreground: '#e0e0e0',
|
||||
cursor: '#e0e0e0',
|
||||
selectionBackground: '#3a3a5e',
|
||||
};
|
||||
|
||||
const buildWsUrl = (wsPath: string) => {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const token = localStorage.getItem('BEARER_TOKEN') ?? '';
|
||||
const separator = wsPath.includes('?') ? '&' : '?';
|
||||
return `${protocol}//${window.location.host}${wsPath}${separator}token=${encodeURIComponent(token)}`;
|
||||
};
|
||||
|
||||
export const TerminalView = ({
|
||||
className,
|
||||
style,
|
||||
wsPath = '/api/terminal/ws',
|
||||
fontSize = 14,
|
||||
fontFamily = 'Menlo, Monaco, "Courier New", monospace',
|
||||
theme,
|
||||
autoFocus = true,
|
||||
onReady,
|
||||
onExit,
|
||||
onDisconnect,
|
||||
}: TerminalViewProps) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const termRef = useRef<XTerm | null>(null);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const fitAddonRef = useRef<FitAddon | null>(null);
|
||||
const isMounted = useMounted();
|
||||
const onReadyRef = useRef<TerminalViewProps['onReady']>(onReady);
|
||||
const onExitRef = useRef<TerminalViewProps['onExit']>(onExit);
|
||||
const onDisconnectRef = useRef<TerminalViewProps['onDisconnect']>(onDisconnect);
|
||||
|
||||
onReadyRef.current = onReady;
|
||||
onExitRef.current = onExit;
|
||||
onDisconnectRef.current = onDisconnect;
|
||||
|
||||
const background = theme?.background ?? DEFAULT_THEME.background;
|
||||
const foreground = theme?.foreground ?? DEFAULT_THEME.foreground;
|
||||
const cursor = theme?.cursor ?? DEFAULT_THEME.cursor;
|
||||
const selectionBackground = theme?.selectionBackground ?? DEFAULT_THEME.selectionBackground;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMounted) return;
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
let disposed = false;
|
||||
const initTimeout = setTimeout(() => {
|
||||
if (disposed) return;
|
||||
const term = new XTerm({
|
||||
cursorBlink: true,
|
||||
fontSize,
|
||||
fontFamily,
|
||||
theme: {
|
||||
background,
|
||||
foreground,
|
||||
cursor,
|
||||
selectionBackground,
|
||||
},
|
||||
});
|
||||
|
||||
const fitAddon = new FitAddon();
|
||||
term.loadAddon(fitAddon);
|
||||
term.open(container);
|
||||
fitAddon.fit();
|
||||
if (autoFocus) term.focus();
|
||||
|
||||
termRef.current = term;
|
||||
fitAddonRef.current = fitAddon;
|
||||
onReadyRef.current?.(term);
|
||||
|
||||
const ws = new WebSocket(buildWsUrl(wsPath));
|
||||
wsRef.current = ws;
|
||||
|
||||
const handleOpen = () => {
|
||||
ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
|
||||
};
|
||||
|
||||
const handleMessage = (ev: MessageEvent) => {
|
||||
try {
|
||||
const msg = JSON.parse(ev.data as string);
|
||||
if (msg.type === 'output') {
|
||||
term.write(msg.data);
|
||||
} else if (msg.type === 'exit') {
|
||||
term.write('\r\n[Process exited]\r\n');
|
||||
onExitRef.current?.();
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
term.write('\r\n[Disconnected]\r\n');
|
||||
onDisconnectRef.current?.();
|
||||
};
|
||||
|
||||
ws.addEventListener('open', handleOpen);
|
||||
ws.addEventListener('message', handleMessage);
|
||||
ws.addEventListener('close', handleClose);
|
||||
|
||||
const dataDisposable = term.onData((data) => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'input', data }));
|
||||
}
|
||||
});
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
fitAddon.fit();
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
|
||||
}
|
||||
});
|
||||
resizeObserver.observe(container);
|
||||
|
||||
const cleanup = () => {
|
||||
dataDisposable.dispose();
|
||||
resizeObserver.disconnect();
|
||||
ws.removeEventListener('open', handleOpen);
|
||||
ws.removeEventListener('message', handleMessage);
|
||||
ws.removeEventListener('close', handleClose);
|
||||
};
|
||||
|
||||
(container as any).__terminalCleanup = cleanup;
|
||||
}, 0);
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
clearTimeout(initTimeout);
|
||||
const cleanup = (container as any).__terminalCleanup as (() => void) | undefined;
|
||||
cleanup?.();
|
||||
delete (container as any).__terminalCleanup;
|
||||
wsRef.current?.close();
|
||||
wsRef.current = null;
|
||||
termRef.current?.dispose();
|
||||
termRef.current = null;
|
||||
fitAddonRef.current = null;
|
||||
};
|
||||
}, [
|
||||
isMounted,
|
||||
wsPath,
|
||||
fontSize,
|
||||
fontFamily,
|
||||
background,
|
||||
foreground,
|
||||
cursor,
|
||||
selectionBackground,
|
||||
autoFocus,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={className}
|
||||
style={{ backgroundColor: background, ...style }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { TerminalView, type TerminalViewProps } from './Terminal';
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "apps",
|
||||
"private": true,
|
||||
"exports": {
|
||||
"./Terminal": "./Terminal/index.ts",
|
||||
"./FileBrowser": "./FileBrowser/index.ts",
|
||||
"./ChatHistory": "./ChatHistory/index.ts",
|
||||
"./Chat": "./Chat/index.ts",
|
||||
"./CodeEditor": "./CodeEditor/index.ts"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user