major apps and hooks refactor
This commit is contained in:
@@ -48,7 +48,6 @@
|
|||||||
"@uiw/react-textarea-code-editor": "^3.1.1",
|
"@uiw/react-textarea-code-editor": "^3.1.1",
|
||||||
"@xterm/addon-fit": "^0.11.0",
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
"@xterm/xterm": "^6.0.0",
|
"@xterm/xterm": "^6.0.0",
|
||||||
"apps": "workspace:*",
|
|
||||||
"argon2": "^0.44.0",
|
"argon2": "^0.44.0",
|
||||||
"bun-plugin-tailwind": "^0.1.2",
|
"bun-plugin-tailwind": "^0.1.2",
|
||||||
"check-password-strength": "^3.0.0",
|
"check-password-strength": "^3.0.0",
|
||||||
@@ -146,9 +145,6 @@
|
|||||||
"drizzle-kit": "^0.31.8",
|
"drizzle-kit": "^0.31.8",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"src/workspaces/apps": {
|
|
||||||
"name": "apps",
|
|
||||||
},
|
|
||||||
"src/workspaces/components": {
|
"src/workspaces/components": {
|
||||||
"name": "components",
|
"name": "components",
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
@@ -247,6 +243,10 @@
|
|||||||
"src/workspaces/officerdev": {
|
"src/workspaces/officerdev": {
|
||||||
"name": "officerdev",
|
"name": "officerdev",
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
|
"dependencies": {
|
||||||
|
"hooks": "workspace:*",
|
||||||
|
"widgets": "workspace:*",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
"src/workspaces/sounds": {
|
"src/workspaces/sounds": {
|
||||||
"name": "sounds",
|
"name": "sounds",
|
||||||
@@ -1061,8 +1061,6 @@
|
|||||||
|
|
||||||
"anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="],
|
"anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="],
|
||||||
|
|
||||||
"apps": ["apps@workspace:src/workspaces/apps"],
|
|
||||||
|
|
||||||
"arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="],
|
"arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="],
|
||||||
|
|
||||||
"argon2": ["argon2@0.44.0", "", { "dependencies": { "@phc/format": "^1.0.0", "cross-env": "^10.0.0", "node-addon-api": "^8.5.0", "node-gyp-build": "^4.8.4" } }, "sha512-zHPGN3S55sihSQo0dBbK0A5qpi2R31z7HZDZnry3ifOyj8bZZnpZND2gpmhnRGO1V/d555RwBqIK5W4Mrmv3ig=="],
|
"argon2": ["argon2@0.44.0", "", { "dependencies": { "@phc/format": "^1.0.0", "cross-env": "^10.0.0", "node-addon-api": "^8.5.0", "node-gyp-build": "^4.8.4" } }, "sha512-zHPGN3S55sihSQo0dBbK0A5qpi2R31z7HZDZnry3ifOyj8bZZnpZND2gpmhnRGO1V/d555RwBqIK5W4Mrmv3ig=="],
|
||||||
|
|||||||
@@ -124,7 +124,6 @@
|
|||||||
"three": "^0.182.0",
|
"three": "^0.182.0",
|
||||||
"types": "workspace:*",
|
"types": "workspace:*",
|
||||||
"vaul": "^1.1.2",
|
"vaul": "^1.1.2",
|
||||||
"apps": "workspace:*",
|
|
||||||
"officerdev": "workspace:*",
|
"officerdev": "workspace:*",
|
||||||
"widgets": "workspace:*",
|
"widgets": "workspace:*",
|
||||||
"ws": "^8.18.1",
|
"ws": "^8.18.1",
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { Card } from '@/components/Card';
|
|||||||
import { FrontmatterBlock } from '../CapabilityPage';
|
import { FrontmatterBlock } from '../CapabilityPage';
|
||||||
import type { CapabilityDetail } from '../CapabilityPage';
|
import type { CapabilityDetail } from '../CapabilityPage';
|
||||||
import type { AutomationSelection } from './AutomationRightPanel';
|
import type { AutomationSelection } from './AutomationRightPanel';
|
||||||
import { TaskRunnerModal } from 'apps/FileBrowser';
|
import { TaskRunnerModal } from 'officerdev';
|
||||||
|
|
||||||
type SelectOption = { value: string; label: string };
|
type SelectOption = { value: string; label: string };
|
||||||
|
|
||||||
|
|||||||
@@ -8,9 +8,8 @@ import { ArrowLeft, Pencil, Plus, Check, X, Trash2, Search, ChevronRight } from
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
|
||||||
import { useClient } from 'hooks/useClient';
|
import { useClient } from 'hooks/useClient';
|
||||||
import { useVisiblePiModels } from '@/state/useModels';
|
|
||||||
import { Card } from '@/components/Card';
|
import { Card } from '@/components/Card';
|
||||||
import { usePi, EmbeddableChat } from 'apps/Chat';
|
import { usePiChat, EmbeddableChat } from 'officerdev';
|
||||||
type CapabilitySummary = {
|
type CapabilitySummary = {
|
||||||
dirName: string;
|
dirName: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -63,14 +62,13 @@ export const CapabilityChat = ({
|
|||||||
description,
|
description,
|
||||||
onResponseEnd,
|
onResponseEnd,
|
||||||
}: CapabilityChatProps) => {
|
}: CapabilityChatProps) => {
|
||||||
const piModels = useVisiblePiModels();
|
|
||||||
const seedFile = `${kind.toUpperCase()}.md`;
|
const seedFile = `${kind.toUpperCase()}.md`;
|
||||||
const promptFrontmatter = `<frontmatter>\ninput file: ${filePath}\n${seedFile}: ${filePath}\ndir: ${resourceDir}\n\nBe aware of any extra files alongside the same dir as the ${kind} file we're handling, for possible extra context. You can also, if pertinent, create scripts or other files that will help you in the future.\n</frontmatter>`;
|
const promptFrontmatter = `<frontmatter>\ninput file: ${filePath}\n${seedFile}: ${filePath}\ndir: ${resourceDir}\n\nBe aware of any extra files alongside the same dir as the ${kind} file we're handling, for possible extra context. You can also, if pertinent, create scripts or other files that will help you in the future.\n</frontmatter>`;
|
||||||
const defaultInput = isNew
|
const defaultInput = isNew
|
||||||
? description ?? `Help me create the content for this new ${kind} file`
|
? description ?? `Help me create the content for this new ${kind} file`
|
||||||
: `Help me understand and improve this ${kind} file`;
|
: `Help me understand and improve this ${kind} file`;
|
||||||
|
|
||||||
const pi = usePi(undefined, undefined, { replaceUrl: false });
|
const pi = usePiChat(undefined, undefined, { replaceUrl: false });
|
||||||
|
|
||||||
const onResponseEndRef = useRef(onResponseEnd);
|
const onResponseEndRef = useRef(onResponseEnd);
|
||||||
onResponseEndRef.current = onResponseEnd;
|
onResponseEndRef.current = onResponseEnd;
|
||||||
@@ -86,7 +84,6 @@ export const CapabilityChat = ({
|
|||||||
return (
|
return (
|
||||||
<EmbeddableChat
|
<EmbeddableChat
|
||||||
chat={pi}
|
chat={pi}
|
||||||
availableModels={piModels}
|
|
||||||
defaultInput={defaultInput}
|
defaultInput={defaultInput}
|
||||||
promptPrefix={promptFrontmatter}
|
promptPrefix={promptFrontmatter}
|
||||||
className="h-full"
|
className="h-full"
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useLocation } from 'react-router';
|
|||||||
import { Trash2 } from 'lucide-react';
|
import { Trash2 } from 'lucide-react';
|
||||||
import { usePanelChannel } from 'hooks/usePanelChannel';
|
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||||
import { useChatSessions } from '@/state/useChatSessions';
|
import { useChatSessions } from '@/state/useChatSessions';
|
||||||
import { usePi, EmbeddableChat } from 'apps/Chat';
|
import { usePiChat, EmbeddableChat } from 'officerdev';
|
||||||
|
|
||||||
export type SelectedSession = {
|
export type SelectedSession = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -70,7 +70,7 @@ function SessionChat({ sessionId, model }: SessionChatProps) {
|
|||||||
|
|
||||||
// We need connection status for the DetailBar, so we still call usePi here
|
// We need connection status for the DetailBar, so we still call usePi here
|
||||||
// TODO: Consider moving DetailBar into EmbeddableChat or exposing status from it
|
// TODO: Consider moving DetailBar into EmbeddableChat or exposing status from it
|
||||||
const chat = usePi(sessionId, model, { replaceUrl: false });
|
const chat = usePiChat(sessionId, model, { replaceUrl: false });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
@@ -99,7 +99,7 @@ function NewChat() {
|
|||||||
const [, setSelected] = usePanelChannel<SelectedSession>(CHANNEL, null);
|
const [, setSelected] = usePanelChannel<SelectedSession>(CHANNEL, null);
|
||||||
|
|
||||||
// We need connection status for DetailBar, so call usePi
|
// We need connection status for DetailBar, so call usePi
|
||||||
const chat = usePi();
|
const chat = usePiChat();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (chat.sessionId) {
|
if (chat.sessionId) {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { MoreVertical, Edit2, Trash2 } from 'lucide-react';
|
import { MoreVertical, Edit2, Trash2 } from 'lucide-react';
|
||||||
import { useChatGroups } from '@/state/useChatGroups';
|
import { useChatGroups } from '@/state/useChatGroups';
|
||||||
import type { GroupEntry } from 'apps/Chat';
|
import type { GroupEntry } from 'officerdev';
|
||||||
|
|
||||||
type GroupContextMenuProps = {
|
type GroupContextMenuProps = {
|
||||||
group: GroupEntry;
|
group: GroupEntry;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useState } from 'react';
|
|||||||
import { MoreVertical, FolderInput, Edit2, Trash2 } from 'lucide-react';
|
import { MoreVertical, FolderInput, Edit2, Trash2 } from 'lucide-react';
|
||||||
import { useChatGroups } from '@/state/useChatGroups';
|
import { useChatGroups } from '@/state/useChatGroups';
|
||||||
import { useChatSessions } from '@/state/useChatSessions';
|
import { useChatSessions } from '@/state/useChatSessions';
|
||||||
import type { SessionEntry } from 'apps/Chat';
|
import type { SessionEntry } from 'officerdev';
|
||||||
|
|
||||||
type SessionContextMenuProps = {
|
type SessionContextMenuProps = {
|
||||||
session: SessionEntry;
|
session: SessionEntry;
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
import { useRef, useEffect, useState } from 'react';
|
import { useRef, useEffect, useState } from 'react';
|
||||||
import { useNavigate, useLocation } from 'react-router';
|
import { useNavigate, useLocation } from 'react-router';
|
||||||
import { useChatSessions } from '@/state/useChatSessions';
|
import { useChatSessions } from '@/state/useChatSessions';
|
||||||
import { SessionBar } from 'apps/ChatHistory';
|
import { SessionBar, EmbeddableChat, type UsePiChatType, type Attachment } from 'officerdev';
|
||||||
import { EmbeddableChat, type UsePiType, type Attachment } from 'apps/Chat';
|
|
||||||
import { Card } from '@/components/Card';
|
import { Card } from '@/components/Card';
|
||||||
|
|
||||||
export type { Attachment };
|
export type { Attachment };
|
||||||
|
|
||||||
type ChatPanelProps = {
|
type ChatPanelProps = {
|
||||||
chat: UsePiType;
|
chat: UsePiChatType;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ChatScreen = ({ chat }: ChatPanelProps) => {
|
export const ChatScreen = ({ chat }: ChatPanelProps) => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { CodeEditorView } from 'apps/CodeEditor';
|
import { CodeEditorView } from 'officerdev';
|
||||||
import { Widget } from 'widgets/Widget';
|
import { Widget } from 'widgets/Widget';
|
||||||
|
|
||||||
export const CodeEditor = () => (
|
export const CodeEditor = () => (
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
import type { LayoutNode } from '@/components/Workspace';
|
import type { LayoutNode } from '@/components/Workspace';
|
||||||
import { WorkspaceView } from '@/components/Workspace';
|
import { WorkspaceView } from '@/components/Workspace';
|
||||||
import { useWorkspacesState } from '@/state/useWorkspacesState';
|
import { useWorkspacesState } from '@/state/useWorkspacesState';
|
||||||
|
import { useFileViewerPanels } from 'officerdev';
|
||||||
import { appRegistry } from '../Workspaces/app-registry';
|
import { appRegistry } from '../Workspaces/app-registry';
|
||||||
import { defaultLayout } from './defaultLayout';
|
import { defaultLayout } from './defaultLayout';
|
||||||
|
|
||||||
export const FilesScreen = () => {
|
export const FilesScreen = () => {
|
||||||
const workspace = useWorkspacesState<LayoutNode>('screens/files', defaultLayout);
|
const workspace = useWorkspacesState<LayoutNode>('screens/files', defaultLayout);
|
||||||
|
const ephemeral = useFileViewerPanels();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-full">
|
<div className="h-full w-full">
|
||||||
<WorkspaceView workspace={workspace} registry={appRegistry} />
|
<WorkspaceView workspace={workspace} registry={appRegistry} ephemeral={ephemeral} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,8 +6,7 @@ import type { LayoutNode, PanelComponents } from '@/components/Workspace';
|
|||||||
import { WorkspaceLayout } from '@/components/Workspace';
|
import { WorkspaceLayout } from '@/components/Workspace';
|
||||||
import { usePanelChannel } from 'hooks/usePanelChannel';
|
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||||
import { useClient } from 'hooks/useClient';
|
import { useClient } from 'hooks/useClient';
|
||||||
import { TerminalView } from 'apps/Terminal';
|
import { TerminalView, FileViewerView } from 'officerdev';
|
||||||
import { FileViewerView } from 'apps/FileViewer';
|
|
||||||
import { appRegistry } from '../../Workspaces/app-registry';
|
import { appRegistry } from '../../Workspaces/app-registry';
|
||||||
import { Resources } from './Resources';
|
import { Resources } from './Resources';
|
||||||
import { ResourceSidebar } from './ResourceSidebar';
|
import { ResourceSidebar } from './ResourceSidebar';
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { useQueryClient } from '@tanstack/react-query';
|
|||||||
import type { LayoutNode, PanelComponents } from '@/components/Workspace';
|
import type { LayoutNode, PanelComponents } from '@/components/Workspace';
|
||||||
import { WorkspaceLayout } from '@/components/Workspace';
|
import { WorkspaceLayout } from '@/components/Workspace';
|
||||||
import { usePanelChannel } from 'hooks/usePanelChannel';
|
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||||
import { TerminalView } from 'apps/Terminal';
|
import { TerminalView } from 'officerdev';
|
||||||
import { appRegistry } from '../Workspaces/app-registry';
|
import { appRegistry } from '../Workspaces/app-registry';
|
||||||
import { createSettingsPanelComponents, type SettingsSectionGroup } from './SettingsPanel';
|
import { createSettingsPanelComponents, type SettingsSectionGroup } from './SettingsPanel';
|
||||||
import { useSettings } from '@/state/useSettings';
|
import { useSettings } from '@/state/useSettings';
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useState, useEffect } from 'react';
|
|||||||
import { Search, AlertCircle, CheckCircle2, Clock, ArrowLeft } from 'lucide-react';
|
import { Search, AlertCircle, CheckCircle2, Clock, ArrowLeft } from 'lucide-react';
|
||||||
import { useClient } from 'hooks/useClient';
|
import { useClient } from 'hooks/useClient';
|
||||||
import { Card } from '@/components/Card';
|
import { Card } from '@/components/Card';
|
||||||
import { MessageBubble, type ChatMessage } from 'apps/Chat';
|
import { MessageBubble, type ChatMessage } from 'officerdev';
|
||||||
|
|
||||||
type LogMetadata = {
|
type LogMetadata = {
|
||||||
filename: string;
|
filename: string;
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { WorkspaceLayout, WorkspaceView, createDefaultLayout } from '@/component
|
|||||||
import type { LayoutNode, WorkspaceDefinition } from '@/components/Workspace';
|
import type { LayoutNode, WorkspaceDefinition } from '@/components/Workspace';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { generateSlug, slugify } from 'helpers/slug';
|
import { generateSlug, slugify } from 'helpers/slug';
|
||||||
import { Files } from 'apps/FileBrowser';
|
import { FileBrowserApp } from 'officerdev';
|
||||||
import { appRegistry } from './app-registry';
|
import { appRegistry } from './app-registry';
|
||||||
import { SELECTED_WORKSPACE_KEY, CREATING_WORKSPACE_KEY, EDITING_WORKSPACE_KEY, NEW_WS_NAME_KEY, NEW_WS_DESC_KEY, NEW_WS_TEMPLATE_KEY } from './constants';
|
import { SELECTED_WORKSPACE_KEY, CREATING_WORKSPACE_KEY, EDITING_WORKSPACE_KEY, NEW_WS_NAME_KEY, NEW_WS_DESC_KEY, NEW_WS_TEMPLATE_KEY } from './constants';
|
||||||
|
|
||||||
@@ -396,7 +396,7 @@ const CreatePanel = () => {
|
|||||||
const CwdFileBrowser = () => {
|
const CwdFileBrowser = () => {
|
||||||
const [currentPath] = useUserState<string>('files/currentPath', '/');
|
const [currentPath] = useUserState<string>('files/currentPath', '/');
|
||||||
const [basePath] = useState(currentPath);
|
const [basePath] = useState(currentPath);
|
||||||
return <Files basePath={basePath} />;
|
return <FileBrowserApp basePath={basePath} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- New Workspace Layout ---
|
// --- New Workspace Layout ---
|
||||||
|
|||||||
@@ -5,15 +5,11 @@ import { Monitor, LayoutGrid, LayoutDashboard, Sparkles, Eye, FolderKanban, Colu
|
|||||||
import { useAuth } from 'hooks/useAuth';
|
import { useAuth } from 'hooks/useAuth';
|
||||||
import type { AppRegistry } from '@/components/Workspace';
|
import type { AppRegistry } from '@/components/Workspace';
|
||||||
import { useWorkspace } from '@/components/Workspace';
|
import { useWorkspace } from '@/components/Workspace';
|
||||||
import { CodeEditorView } from 'apps/CodeEditor';
|
import { CodeEditorView, TerminalView, FileViewerProvider, FileViewerHeader, FileViewerBody, EmbeddableChat, ChatLauncher, usePiChat, FileBrowserApp } from 'officerdev';
|
||||||
import { TerminalView } from 'apps/Terminal';
|
|
||||||
import { useWorkspacesState } from '@/state/useWorkspacesState';
|
import { useWorkspacesState } from '@/state/useWorkspacesState';
|
||||||
import { usePanelChannel } from 'hooks/usePanelChannel';
|
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||||
import { FileViewerProvider, FileViewerHeader, FileViewerBody } from 'apps/FileViewer';
|
|
||||||
import { EmbeddableChat, ChatLauncher, usePi } from 'apps/Chat';
|
|
||||||
import { useVisiblePiModels } from '@/state/useModels';
|
import { useVisiblePiModels } from '@/state/useModels';
|
||||||
import { ChatHistoryApp as ChatHistory } from '../ChatHistory';
|
import { ChatHistoryApp as ChatHistory } from '../ChatHistory';
|
||||||
import { Files } from 'apps/FileBrowser';
|
|
||||||
import { Catalog } from 'sounds';
|
import { Catalog } from 'sounds';
|
||||||
import { WorkspaceListApp } from './WorkspaceListApp';
|
import { WorkspaceListApp } from './WorkspaceListApp';
|
||||||
import { ProjectListApp } from '../Projects/ProjectListApp';
|
import { ProjectListApp } from '../Projects/ProjectListApp';
|
||||||
@@ -23,7 +19,7 @@ import { WidgetPanel } from 'widgets/WidgetPanel';
|
|||||||
const ChatWidget = () => <EmbeddableChat className="h-full" />;
|
const ChatWidget = () => <EmbeddableChat className="h-full" />;
|
||||||
|
|
||||||
const ChatLauncherWidget = () => {
|
const ChatLauncherWidget = () => {
|
||||||
const pi = usePi();
|
const pi = usePiChat();
|
||||||
const models = useVisiblePiModels();
|
const models = useVisiblePiModels();
|
||||||
console.log(models)
|
console.log(models)
|
||||||
return (
|
return (
|
||||||
@@ -41,7 +37,7 @@ const cwdToPath = (cwd: string) => (cwd === '~' ? '/' : cwd.slice(1));
|
|||||||
const FileBrowserWrapper = () => {
|
const FileBrowserWrapper = () => {
|
||||||
const { cwd } = useWorkspace();
|
const { cwd } = useWorkspace();
|
||||||
const basePath = cwdToPath(cwd);
|
const basePath = cwdToPath(cwd);
|
||||||
return <Files basePath={basePath} />;
|
return <FileBrowserApp basePath={basePath} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
const CodeEditorWrapper = () => <CodeEditorView className="h-full w-full" />;
|
const CodeEditorWrapper = () => <CodeEditorView className="h-full w-full" />;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { GroupEntry } from 'apps/Chat';
|
import type { GroupEntry } from 'officerdev';
|
||||||
import { useAuth } from 'hooks/useAuth';
|
import { useAuth } from 'hooks/useAuth';
|
||||||
import { useClient } from 'hooks/useClient';
|
import { useClient } from 'hooks/useClient';
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { SessionEntry, ChatMessage, Message } from 'apps/Chat';
|
import type { SessionEntry, ChatMessage, Message } from 'officerdev';
|
||||||
import { useAuth } from 'hooks/useAuth';
|
import { useAuth } from 'hooks/useAuth';
|
||||||
import { useClient } from 'hooks/useClient';
|
import { useClient } from 'hooks/useClient';
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useQuery } from '@tanstack/react-query';
|
|||||||
import { useClient } from 'hooks/useClient';
|
import { useClient } from 'hooks/useClient';
|
||||||
import { useAuth } from 'hooks/useAuth';
|
import { useAuth } from 'hooks/useAuth';
|
||||||
import { useSettings } from './useSettings';
|
import { useSettings } from './useSettings';
|
||||||
import type { ModelOption } from 'apps/Chat';
|
import type { ModelOption } from 'officerdev';
|
||||||
|
|
||||||
export type { ModelOption };
|
export type { ModelOption };
|
||||||
|
|
||||||
|
|||||||
@@ -1,195 +0,0 @@
|
|||||||
import { useState, useMemo, useRef, useEffect, type KeyboardEvent } from 'react';
|
|
||||||
import { Send } from 'lucide-react';
|
|
||||||
import { toast } from 'sonner';
|
|
||||||
import { useClient } from 'hooks/useClient';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import type { ModelOption, Attachment } from './types';
|
|
||||||
import { ModelSelector } from './ModelSelector';
|
|
||||||
import { AttachmentList } from './AttachmentList';
|
|
||||||
import { AttachButton } from './AttachButton';
|
|
||||||
import { WebpageDialog } from './WebpageDialog';
|
|
||||||
|
|
||||||
type ChatLauncherProps = {
|
|
||||||
availableModels: ModelOption[];
|
|
||||||
selectedModel: string | null;
|
|
||||||
onModelChange: (modelId: string) => void;
|
|
||||||
onSubmit: (data: {
|
|
||||||
prompt: string;
|
|
||||||
model: string | null;
|
|
||||||
attachmentIds?: string[];
|
|
||||||
images?: { filename: string; dataUrl: string }[];
|
|
||||||
}) => void;
|
|
||||||
placeholder?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function ChatLauncher({
|
|
||||||
availableModels,
|
|
||||||
selectedModel,
|
|
||||||
onModelChange,
|
|
||||||
onSubmit,
|
|
||||||
placeholder = 'What do you want to work on now?',
|
|
||||||
}: ChatLauncherProps) {
|
|
||||||
const client = useClient();
|
|
||||||
const [input, setInput] = useState('');
|
|
||||||
const [attachments, setAttachments] = useState<Attachment[]>([]);
|
|
||||||
const [urlDialogOpen, setUrlDialogOpen] = useState(false);
|
|
||||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
|
||||||
|
|
||||||
const handleAttachWebpage = async (url: string) => {
|
|
||||||
const idx = attachments.length;
|
|
||||||
setAttachments((prev) => [
|
|
||||||
...prev,
|
|
||||||
{ type: 'webpage', url, title: '', content: '', attachmentId: '', loading: true },
|
|
||||||
]);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await client.post<{ url: string; title: string; content: string; attachmentId: string }>('/scrape', {
|
|
||||||
url,
|
|
||||||
provider: 'pi-mono',
|
|
||||||
});
|
|
||||||
setAttachments((prev) =>
|
|
||||||
prev.map((a, i) =>
|
|
||||||
i === idx
|
|
||||||
? { ...a, title: res.title, content: res.content, attachmentId: res.attachmentId, loading: false }
|
|
||||||
: a,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
setAttachments((prev) => prev.filter((_, i) => i !== idx));
|
|
||||||
toast.error('Failed to scrape webpage');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAttachImage = async (file: File) => {
|
|
||||||
const idx = attachments.length;
|
|
||||||
setAttachments((prev) => [
|
|
||||||
...prev,
|
|
||||||
{ type: 'image', filename: file.name, dataUrl: '', attachmentId: '', loading: true },
|
|
||||||
]);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('file', file);
|
|
||||||
formData.append('provider', 'pi-mono');
|
|
||||||
|
|
||||||
const res = await client.post<{ filename: string; dataUrl: string; attachmentId: string }>('/upload', formData);
|
|
||||||
setAttachments((prev) =>
|
|
||||||
prev.map((a, i) =>
|
|
||||||
i === idx ? { ...a, dataUrl: res.dataUrl, attachmentId: res.attachmentId, loading: false } : a,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
setAttachments((prev) => prev.filter((_, i) => i !== idx));
|
|
||||||
toast.error('Failed to upload image');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = () => {
|
|
||||||
const text = input.trim();
|
|
||||||
if (!text) return;
|
|
||||||
|
|
||||||
let prompt = text;
|
|
||||||
const attachmentIds: string[] = [];
|
|
||||||
const images: { filename: string; dataUrl: string }[] = [];
|
|
||||||
|
|
||||||
for (const a of attachments) {
|
|
||||||
if (a.loading) continue;
|
|
||||||
if (a.type === 'webpage' && a.content) {
|
|
||||||
prompt = `[Attached webpage: ${a.url}]\n${a.content}\n\n${prompt}`;
|
|
||||||
} else if (a.type === 'image' && a.dataUrl) {
|
|
||||||
prompt = `[Attached image: ${a.filename}]\n\n${prompt}`;
|
|
||||||
images.push({ filename: a.filename, dataUrl: a.dataUrl });
|
|
||||||
}
|
|
||||||
attachmentIds.push(a.attachmentId);
|
|
||||||
}
|
|
||||||
|
|
||||||
onSubmit({
|
|
||||||
prompt,
|
|
||||||
model: selectedModel,
|
|
||||||
attachmentIds: attachmentIds.length > 0 ? attachmentIds : undefined,
|
|
||||||
images: images.length > 0 ? images : undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Reset form
|
|
||||||
setInput('');
|
|
||||||
setAttachments([]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleKeyDown = (ev: KeyboardEvent<HTMLTextAreaElement>) => {
|
|
||||||
if (ev.key === 'Enter' && !ev.shiftKey) {
|
|
||||||
ev.preventDefault();
|
|
||||||
handleSubmit();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleImagePaste = (file: File) => {
|
|
||||||
handleAttachImage(file);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Auto-resize textarea
|
|
||||||
useEffect(() => {
|
|
||||||
const textarea = textareaRef.current;
|
|
||||||
if (!textarea) return;
|
|
||||||
textarea.style.height = 'auto';
|
|
||||||
textarea.style.height = Math.min(textarea.scrollHeight, 160) + 'px';
|
|
||||||
}, [input]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="p-4 pb-2 pt-1">
|
|
||||||
<AttachmentList attachments={attachments} onRemove={(i) => setAttachments((prev) => prev.filter((_, j) => j !== i))} />
|
|
||||||
|
|
||||||
<div className="flex items-end gap-2">
|
|
||||||
<AttachButton
|
|
||||||
size="md"
|
|
||||||
onAttachImage={handleAttachImage}
|
|
||||||
onAttachWebpage={() => setUrlDialogOpen(true)}
|
|
||||||
/>
|
|
||||||
<textarea
|
|
||||||
ref={textareaRef}
|
|
||||||
value={input}
|
|
||||||
onChange={(ev) => setInput(ev.target.value)}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
onPaste={(ev) => {
|
|
||||||
const items = ev.clipboardData?.items;
|
|
||||||
if (!items) return;
|
|
||||||
for (const item of Array.from(items)) {
|
|
||||||
if (item.type.startsWith('image/')) {
|
|
||||||
ev.preventDefault();
|
|
||||||
const file = item.getAsFile();
|
|
||||||
if (file) handleImagePaste(file);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
placeholder={placeholder}
|
|
||||||
rows={1}
|
|
||||||
className="flex-1 resize-none bg-transparent px-2 py-2 text-duck-dark placeholder:text-duck-dark/30 focus:outline-none text-lg"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
onClick={handleSubmit}
|
|
||||||
disabled={!input.trim()}
|
|
||||||
size="icon"
|
|
||||||
className="shrink-0 h-10 w-10 bg-duck-teal hover:bg-duck-teal/90 cursor-pointer disabled:opacity-40"
|
|
||||||
>
|
|
||||||
<Send className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ModelSelector
|
|
||||||
messages={[]}
|
|
||||||
availableModels={availableModels}
|
|
||||||
selectedModel={selectedModel}
|
|
||||||
onModelChange={onModelChange}
|
|
||||||
model={selectedModel}
|
|
||||||
isConnected={true}
|
|
||||||
isGenerating={false}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<WebpageDialog
|
|
||||||
open={urlDialogOpen}
|
|
||||||
onOpenChange={setUrlDialogOpen}
|
|
||||||
onSubmit={handleAttachWebpage}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,274 +0,0 @@
|
|||||||
import type { KeyboardEvent } from 'react';
|
|
||||||
import { useRef, useEffect, useState } from 'react';
|
|
||||||
import { toast } from 'sonner';
|
|
||||||
import { useClient } from 'hooks/useClient';
|
|
||||||
import { useVisiblePiModels } from '@/state/useModels';
|
|
||||||
import { usePi } from './usePi';
|
|
||||||
import { MessageList } from './MessageList';
|
|
||||||
import { InputArea } from './InputArea';
|
|
||||||
import type { Attachment } from './types';
|
|
||||||
import { useSlashCommands } from './useSlashCommands';
|
|
||||||
|
|
||||||
type EmbeddableChatProps = {
|
|
||||||
sessionId?: string;
|
|
||||||
initialModel?: string | null;
|
|
||||||
initialMessage?: {
|
|
||||||
text: string;
|
|
||||||
attachmentIds?: string[];
|
|
||||||
images?: { filename: string; dataUrl: string }[];
|
|
||||||
cwd?: { root?: string; path: string };
|
|
||||||
};
|
|
||||||
defaultInput?: string;
|
|
||||||
promptPrefix?: string;
|
|
||||||
className?: string;
|
|
||||||
cwd?: { root?: string; path: string };
|
|
||||||
autoSend?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const EmbeddableChat = ({
|
|
||||||
sessionId: initialSessionId,
|
|
||||||
initialModel,
|
|
||||||
initialMessage,
|
|
||||||
defaultInput = '',
|
|
||||||
promptPrefix,
|
|
||||||
className,
|
|
||||||
cwd,
|
|
||||||
autoSend = false,
|
|
||||||
}: EmbeddableChatProps) => {
|
|
||||||
const chat = usePi(initialSessionId, initialModel);
|
|
||||||
const availableModels = useVisiblePiModels();
|
|
||||||
const {
|
|
||||||
messages,
|
|
||||||
streamingText,
|
|
||||||
isConnected,
|
|
||||||
isGenerating,
|
|
||||||
sessionId,
|
|
||||||
model,
|
|
||||||
selectedModel,
|
|
||||||
setSelectedModel,
|
|
||||||
sendPrompt,
|
|
||||||
stopGeneration,
|
|
||||||
} = chat;
|
|
||||||
|
|
||||||
const client = useClient();
|
|
||||||
const [input, setInput] = useState(defaultInput);
|
|
||||||
const [attachments, setAttachments] = useState<Attachment[]>([]);
|
|
||||||
const [showJumpToBottom, setShowJumpToBottom] = useState(false);
|
|
||||||
const [commandFeedback, setCommandFeedback] = useState<string | null>(null);
|
|
||||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
|
||||||
const scrollViewportRef = useRef<HTMLDivElement | null>(null);
|
|
||||||
const bottomRef = useRef<HTMLDivElement | null>(null);
|
|
||||||
const userScrolledRef = useRef(false);
|
|
||||||
|
|
||||||
const slashCommandHandler = useSlashCommands({ sessionId });
|
|
||||||
|
|
||||||
const handleAttachWebpage = async (url: string) => {
|
|
||||||
const idx = attachments.length;
|
|
||||||
setAttachments((prev) => [
|
|
||||||
...prev,
|
|
||||||
{ type: 'webpage', url, title: '', content: '', attachmentId: '', loading: true },
|
|
||||||
]);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await client.post<{ url: string; title: string; content: string; attachmentId: string }>('/scrape', {
|
|
||||||
url,
|
|
||||||
sessionId: sessionId ?? undefined,
|
|
||||||
provider: 'pi-mono',
|
|
||||||
});
|
|
||||||
setAttachments((prev) =>
|
|
||||||
prev.map((a, i) =>
|
|
||||||
i === idx
|
|
||||||
? { ...a, title: res.title, content: res.content, attachmentId: res.attachmentId, loading: false }
|
|
||||||
: a,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
setAttachments((prev) => prev.filter((_, i) => i !== idx));
|
|
||||||
toast.error('Failed to scrape webpage');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAttachImage = async (file: File) => {
|
|
||||||
const idx = attachments.length;
|
|
||||||
setAttachments((prev) => [
|
|
||||||
...prev,
|
|
||||||
{ type: 'image', filename: file.name, dataUrl: '', attachmentId: '', loading: true },
|
|
||||||
]);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('file', file);
|
|
||||||
if (sessionId) formData.append('sessionId', sessionId);
|
|
||||||
formData.append('provider', 'pi-mono');
|
|
||||||
|
|
||||||
const res = await client.post<{ filename: string; dataUrl: string; attachmentId: string }>('/upload', formData);
|
|
||||||
setAttachments((prev) =>
|
|
||||||
prev.map((a, i) =>
|
|
||||||
i === idx ? { ...a, dataUrl: res.dataUrl, attachmentId: res.attachmentId, loading: false } : a,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
setAttachments((prev) => prev.filter((_, i) => i !== idx));
|
|
||||||
toast.error('Failed to upload image');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRemoveAttachment = (index: number) => {
|
|
||||||
setAttachments((prev) => prev.filter((_, i) => i !== index));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSend = async () => {
|
|
||||||
const text = input.trim();
|
|
||||||
if (!text || isGenerating) return;
|
|
||||||
|
|
||||||
// Handle slash commands
|
|
||||||
if (text.startsWith('/')) {
|
|
||||||
const result = await slashCommandHandler.execute(text);
|
|
||||||
if (result.handled) {
|
|
||||||
setCommandFeedback(result.feedback);
|
|
||||||
setInput('');
|
|
||||||
if (textareaRef.current) textareaRef.current.style.height = 'auto';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setCommandFeedback(null);
|
|
||||||
|
|
||||||
// Prepend metadata/attachment content to the prompt
|
|
||||||
let prompt = promptPrefix ? `${promptPrefix}\n\n${text}` : text;
|
|
||||||
const ids: string[] = [];
|
|
||||||
const images: { filename: string; dataUrl: string }[] = [];
|
|
||||||
for (const a of attachments) {
|
|
||||||
if (a.loading) continue;
|
|
||||||
if (a.type === 'webpage' && a.content) {
|
|
||||||
prompt = `[Attached webpage: ${a.url}]\n${a.content}\n\n${prompt}`;
|
|
||||||
} else if (a.type === 'image' && a.dataUrl) {
|
|
||||||
prompt = `[Attached image: ${a.filename}]\n\n${prompt}`;
|
|
||||||
images.push({ filename: a.filename, dataUrl: a.dataUrl });
|
|
||||||
}
|
|
||||||
ids.push(a.attachmentId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// On first message (no sessionId), include attachmentIds so server can relocate tmp files
|
|
||||||
const cwdForFirst = !sessionId ? cwd : undefined;
|
|
||||||
sendPrompt(
|
|
||||||
prompt,
|
|
||||||
!sessionId && ids.length > 0 ? ids : undefined,
|
|
||||||
images.length > 0 ? images : undefined,
|
|
||||||
cwdForFirst,
|
|
||||||
);
|
|
||||||
setAttachments([]);
|
|
||||||
setInput('');
|
|
||||||
userScrolledRef.current = false;
|
|
||||||
if (textareaRef.current) {
|
|
||||||
textareaRef.current.style.height = 'auto';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleKeyDown = (ev: KeyboardEvent<HTMLTextAreaElement>) => {
|
|
||||||
if (ev.key === 'Enter' && !ev.shiftKey) {
|
|
||||||
ev.preventDefault();
|
|
||||||
handleSend();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Auto-resize textarea
|
|
||||||
useEffect(() => {
|
|
||||||
const textarea = textareaRef.current;
|
|
||||||
if (!textarea) return;
|
|
||||||
textarea.style.height = 'auto';
|
|
||||||
textarea.style.height = Math.min(textarea.scrollHeight, 200) + 'px';
|
|
||||||
}, [input]);
|
|
||||||
|
|
||||||
// Auto-scroll to bottom on new messages
|
|
||||||
useEffect(() => {
|
|
||||||
if (!userScrolledRef.current) {
|
|
||||||
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
|
|
||||||
}
|
|
||||||
}, [messages, streamingText]);
|
|
||||||
|
|
||||||
// Detect user scrolling up
|
|
||||||
useEffect(() => {
|
|
||||||
const viewport = scrollViewportRef.current;
|
|
||||||
if (!viewport) return;
|
|
||||||
|
|
||||||
const handleScroll = () => {
|
|
||||||
const { scrollTop, scrollHeight, clientHeight } = viewport;
|
|
||||||
const atBottom = scrollHeight - scrollTop - clientHeight < 60;
|
|
||||||
userScrolledRef.current = !atBottom;
|
|
||||||
setShowJumpToBottom(!atBottom);
|
|
||||||
};
|
|
||||||
|
|
||||||
viewport.addEventListener('scroll', handleScroll);
|
|
||||||
return () => viewport.removeEventListener('scroll', handleScroll);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const jumpToBottom = () => {
|
|
||||||
userScrolledRef.current = false;
|
|
||||||
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
|
|
||||||
};
|
|
||||||
|
|
||||||
// Focus textarea on mount
|
|
||||||
useEffect(() => {
|
|
||||||
textareaRef.current?.focus();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Auto-send initial message when provided
|
|
||||||
const initialSentRef = useRef(false);
|
|
||||||
useEffect(() => {
|
|
||||||
if (initialMessage && isConnected && !initialSentRef.current) {
|
|
||||||
initialSentRef.current = true;
|
|
||||||
if (initialModel) setSelectedModel(initialModel);
|
|
||||||
sendPrompt(
|
|
||||||
initialMessage.text,
|
|
||||||
initialMessage.attachmentIds,
|
|
||||||
initialMessage.images,
|
|
||||||
initialMessage.cwd,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}, [initialMessage, isConnected]);
|
|
||||||
|
|
||||||
// Auto-send first message when autoSend is enabled
|
|
||||||
const autoSentRef = useRef(false);
|
|
||||||
useEffect(() => {
|
|
||||||
if (autoSend && isConnected && !messages.length && input.trim() && !autoSentRef.current) {
|
|
||||||
autoSentRef.current = true;
|
|
||||||
handleSend();
|
|
||||||
}
|
|
||||||
}, [autoSend, isConnected, messages.length, input]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`flex flex-col ${className ?? ''}`}>
|
|
||||||
<MessageList
|
|
||||||
messages={messages}
|
|
||||||
streamingText={streamingText}
|
|
||||||
isGenerating={isGenerating}
|
|
||||||
showJumpToBottom={showJumpToBottom}
|
|
||||||
onJumpToBottom={jumpToBottom}
|
|
||||||
onQuestionAnswer={(text) => sendPrompt(text)}
|
|
||||||
scrollViewportRef={scrollViewportRef}
|
|
||||||
bottomRef={bottomRef}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<InputArea
|
|
||||||
input={input}
|
|
||||||
onInputChange={setInput}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
onSend={handleSend}
|
|
||||||
onStop={stopGeneration}
|
|
||||||
isGenerating={isGenerating}
|
|
||||||
isConnected={isConnected}
|
|
||||||
commandFeedback={commandFeedback}
|
|
||||||
textareaRef={textareaRef}
|
|
||||||
messages={messages}
|
|
||||||
availableModels={availableModels}
|
|
||||||
selectedModel={selectedModel}
|
|
||||||
onModelChange={setSelectedModel}
|
|
||||||
model={model}
|
|
||||||
attachments={attachments}
|
|
||||||
onAttachWebpage={handleAttachWebpage}
|
|
||||||
onAttachImage={handleAttachImage}
|
|
||||||
onRemoveAttachment={handleRemoveAttachment}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,253 +0,0 @@
|
|||||||
import type { KeyboardEvent, RefObject } from 'react';
|
|
||||||
import { useState, useRef } from 'react';
|
|
||||||
import { Loader2, Mic, Send, Square } from 'lucide-react';
|
|
||||||
import { toast } from 'sonner';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import type { ModelOption } from '@/state/useModels';
|
|
||||||
import type { ChatMessage, Attachment } from './types';
|
|
||||||
import { ModelSelector } from './ModelSelector';
|
|
||||||
import { AttachmentList } from './AttachmentList';
|
|
||||||
import { AttachButton } from './AttachButton';
|
|
||||||
import { WebpageDialog } from './WebpageDialog';
|
|
||||||
|
|
||||||
const blobToWav = async (blob: Blob): Promise<Blob> => {
|
|
||||||
const ctx = new AudioContext();
|
|
||||||
const buf = await ctx.decodeAudioData(await blob.arrayBuffer());
|
|
||||||
await ctx.close();
|
|
||||||
|
|
||||||
const samples = buf.getChannelData(0);
|
|
||||||
const len = samples.length;
|
|
||||||
const sr = buf.sampleRate;
|
|
||||||
const ab = new ArrayBuffer(44 + len * 2);
|
|
||||||
const v = new DataView(ab);
|
|
||||||
|
|
||||||
const s = (o: number, str: string) => {
|
|
||||||
for (let i = 0; i < str.length; i++) v.setUint8(o + i, str.charCodeAt(i));
|
|
||||||
};
|
|
||||||
s(0, 'RIFF');
|
|
||||||
v.setUint32(4, 36 + len * 2, true);
|
|
||||||
s(8, 'WAVE');
|
|
||||||
s(12, 'fmt ');
|
|
||||||
v.setUint32(16, 16, true);
|
|
||||||
v.setUint16(20, 1, true);
|
|
||||||
v.setUint16(22, 1, true);
|
|
||||||
v.setUint32(24, sr, true);
|
|
||||||
v.setUint32(28, sr * 2, true);
|
|
||||||
v.setUint16(32, 2, true);
|
|
||||||
v.setUint16(34, 16, true);
|
|
||||||
s(36, 'data');
|
|
||||||
v.setUint32(40, len * 2, true);
|
|
||||||
|
|
||||||
for (let i = 0; i < len; i++) {
|
|
||||||
const val = Math.max(-1, Math.min(1, samples[i]!));
|
|
||||||
v.setInt16(44 + i * 2, val < 0 ? val * 0x8000 : val * 0x7fff, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Blob([ab], { type: 'audio/wav' });
|
|
||||||
};
|
|
||||||
|
|
||||||
type InputAreaProps = {
|
|
||||||
input: string;
|
|
||||||
onInputChange: (value: string) => void;
|
|
||||||
onKeyDown: (ev: KeyboardEvent<HTMLTextAreaElement>) => void;
|
|
||||||
onSend: () => void;
|
|
||||||
onStop: () => void;
|
|
||||||
isGenerating: boolean;
|
|
||||||
isConnected: boolean;
|
|
||||||
commandFeedback: string | null;
|
|
||||||
textareaRef: RefObject<HTMLTextAreaElement | null>;
|
|
||||||
messages: ChatMessage[];
|
|
||||||
availableModels: ModelOption[];
|
|
||||||
selectedModel: string | null;
|
|
||||||
onModelChange: (modelId: string) => void;
|
|
||||||
model: string | null;
|
|
||||||
attachments: Attachment[];
|
|
||||||
onAttachWebpage: (url: string) => void;
|
|
||||||
onAttachImage: (file: File) => void;
|
|
||||||
onRemoveAttachment: (index: number) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const InputArea = ({
|
|
||||||
input,
|
|
||||||
onInputChange,
|
|
||||||
onKeyDown,
|
|
||||||
onSend,
|
|
||||||
onStop,
|
|
||||||
isGenerating,
|
|
||||||
isConnected,
|
|
||||||
commandFeedback,
|
|
||||||
textareaRef,
|
|
||||||
messages,
|
|
||||||
availableModels,
|
|
||||||
selectedModel,
|
|
||||||
onModelChange,
|
|
||||||
model,
|
|
||||||
attachments,
|
|
||||||
onAttachWebpage,
|
|
||||||
onAttachImage,
|
|
||||||
onRemoveAttachment,
|
|
||||||
}: InputAreaProps) => {
|
|
||||||
const [urlDialogOpen, setUrlDialogOpen] = useState(false);
|
|
||||||
const [recording, setRecording] = useState(false);
|
|
||||||
const [transcribing, setTranscribing] = useState(false);
|
|
||||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
|
||||||
const chunksRef = useRef<Blob[]>([]);
|
|
||||||
|
|
||||||
const handleMicClick = async () => {
|
|
||||||
if (recording) {
|
|
||||||
const recorder = mediaRecorderRef.current;
|
|
||||||
if (!recorder) return;
|
|
||||||
|
|
||||||
setRecording(false);
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (recorder.state === 'inactive') {
|
|
||||||
recorder.stream.getTracks().forEach((t) => t.stop());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const blob = await new Promise<Blob>((resolve, reject) => {
|
|
||||||
const timeout = setTimeout(() => reject(new Error('Recording stop timed out')), 5000);
|
|
||||||
recorder.onstop = () => {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
resolve(new Blob(chunksRef.current, { type: recorder.mimeType }));
|
|
||||||
chunksRef.current = [];
|
|
||||||
};
|
|
||||||
recorder.stop();
|
|
||||||
});
|
|
||||||
|
|
||||||
recorder.stream.getTracks().forEach((t) => t.stop());
|
|
||||||
|
|
||||||
if (blob.size === 0) {
|
|
||||||
toast.error('No audio was captured');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setTranscribing(true);
|
|
||||||
try {
|
|
||||||
const wav = await blobToWav(blob);
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('file', wav, 'recording.wav');
|
|
||||||
formData.append('temperature', '0.0');
|
|
||||||
formData.append('temperature_inc', '0.2');
|
|
||||||
formData.append('response_format', 'json');
|
|
||||||
|
|
||||||
const res = await fetch('http://macmini:8178/inference', { method: 'POST', body: formData });
|
|
||||||
if (!res.ok) throw new Error(`Whisper returned ${res.status}`);
|
|
||||||
const json = await res.json();
|
|
||||||
if (json.error) throw new Error(json.error);
|
|
||||||
const text = (json.text ?? '').trim();
|
|
||||||
if (text) onInputChange(input + (input.length > 0 ? ' ' : '') + text);
|
|
||||||
} finally {
|
|
||||||
setTranscribing(false);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
recorder.stream?.getTracks().forEach((t) => t.stop());
|
|
||||||
chunksRef.current = [];
|
|
||||||
toast.error(err instanceof Error ? err.message : 'Recording failed');
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
||||||
const recorder = new MediaRecorder(stream);
|
|
||||||
mediaRecorderRef.current = recorder;
|
|
||||||
chunksRef.current = [];
|
|
||||||
|
|
||||||
recorder.ondataavailable = (ev) => {
|
|
||||||
if (ev.data.size > 0) chunksRef.current.push(ev.data);
|
|
||||||
};
|
|
||||||
|
|
||||||
recorder.start(250);
|
|
||||||
setRecording(true);
|
|
||||||
} catch (err) {
|
|
||||||
toast.error(err instanceof Error ? err.message : 'Could not access microphone');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="shrink-0 border-t border-duck-dark/10 bg-background/60 p-2 md:p-3">
|
|
||||||
{commandFeedback && (
|
|
||||||
<div className="mb-2 px-3 py-1.5 text-xs text-duck-teal bg-duck-teal/10 rounded-md">{commandFeedback}</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<AttachmentList attachments={attachments} onRemove={onRemoveAttachment} />
|
|
||||||
|
|
||||||
<div className="flex items-end gap-1 md:gap-2">
|
|
||||||
<AttachButton
|
|
||||||
onAttachImage={onAttachImage}
|
|
||||||
onAttachWebpage={() => setUrlDialogOpen(true)}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={transcribing}
|
|
||||||
onClick={handleMicClick}
|
|
||||||
className="relative shrink-0 h-7 w-7 md:h-9 md:w-9 flex items-center justify-center rounded-lg text-duck-dark/40 hover:text-duck-dark/70 hover:bg-duck-dark/5 transition-colors cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
{transcribing ? (
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
) : recording ? (
|
|
||||||
<>
|
|
||||||
<span className="absolute inset-0 rounded-lg animate-ping bg-red-400/30" />
|
|
||||||
<Square className="h-3.5 w-3.5 text-red-500" />
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<Mic className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<textarea
|
|
||||||
ref={textareaRef}
|
|
||||||
value={input}
|
|
||||||
onChange={(ev) => onInputChange(ev.target.value)}
|
|
||||||
onKeyDown={onKeyDown}
|
|
||||||
onPaste={(ev) => {
|
|
||||||
const items = ev.clipboardData?.items;
|
|
||||||
if (!items) return;
|
|
||||||
for (const item of Array.from(items)) {
|
|
||||||
if (item.type.startsWith('image/')) {
|
|
||||||
ev.preventDefault();
|
|
||||||
const file = item.getAsFile();
|
|
||||||
if (file) onAttachImage(file);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
placeholder="Type a message..."
|
|
||||||
rows={1}
|
|
||||||
className="min-w-0 flex-1 resize-none rounded-lg border border-duck-dark/20 bg-background/80 px-2 py-1.5 md:px-3 md:py-2 text-base md:text-sm text-duck-dark placeholder:text-duck-dark/30 focus:outline-none focus:ring-2 focus:ring-duck-teal/30 focus:border-duck-teal/50"
|
|
||||||
/>
|
|
||||||
{isGenerating ? (
|
|
||||||
<Button onClick={onStop} variant="destructive" size="icon" className="shrink-0 h-7 w-7 md:h-9 md:w-9 cursor-pointer">
|
|
||||||
<Square className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
onClick={onSend}
|
|
||||||
disabled={!input.trim() || !isConnected}
|
|
||||||
size="icon"
|
|
||||||
className="shrink-0 h-7 w-7 md:h-9 md:w-9 bg-duck-teal hover:bg-duck-teal/90 cursor-pointer disabled:opacity-40"
|
|
||||||
>
|
|
||||||
<Send className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<ModelSelector
|
|
||||||
messages={messages}
|
|
||||||
availableModels={availableModels}
|
|
||||||
selectedModel={selectedModel}
|
|
||||||
onModelChange={onModelChange}
|
|
||||||
model={model}
|
|
||||||
isConnected={isConnected}
|
|
||||||
isGenerating={isGenerating}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<WebpageDialog
|
|
||||||
open={urlDialogOpen}
|
|
||||||
onOpenChange={setUrlDialogOpen}
|
|
||||||
onSubmit={onAttachWebpage}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
export { MessageList } from './MessageList';
|
|
||||||
export { MessageBubble, StreamingBubble } from './MessageBubble';
|
|
||||||
export { ToolActivity } from './ToolActivity';
|
|
||||||
export { QuestionActivity } from './QuestionActivity';
|
|
||||||
export { ModelSelector } from './ModelSelector';
|
|
||||||
export { InputArea } from './InputArea';
|
|
||||||
export { ChatLauncher } from './ChatLauncher';
|
|
||||||
export { AttachmentList } from './AttachmentList';
|
|
||||||
export { AttachButton } from './AttachButton';
|
|
||||||
export { WebpageDialog } from './WebpageDialog';
|
|
||||||
export { EmbeddableChat } from './EmbeddableChat';
|
|
||||||
export { usePi, type UsePiType } from './usePi';
|
|
||||||
export { ChatList } from './ChatList';
|
|
||||||
export { useSlashCommands } from './useSlashCommands';
|
|
||||||
export { useChatSessions, type UseChatSessionsType } from './useChatSessions';
|
|
||||||
export { useChatSession, type UseChatSessionType } from './useChatSession';
|
|
||||||
|
|
||||||
export * from './types';
|
|
||||||
@@ -1,225 +0,0 @@
|
|||||||
import { useRef, useState, useCallback } from 'react';
|
|
||||||
import { RefreshCw, FolderPlus, Upload, FolderUp, Scissors, Copy, ClipboardPaste, Trash2, X, Check, Download } from 'lucide-react';
|
|
||||||
|
|
||||||
type ToolbarProps = {
|
|
||||||
onRefresh: () => void;
|
|
||||||
onCreateDir: (name: string) => void;
|
|
||||||
onUpload: (files: FileList) => void;
|
|
||||||
selectionCount: number;
|
|
||||||
hasClipboard: boolean;
|
|
||||||
onCut: () => void;
|
|
||||||
onCopy: () => void;
|
|
||||||
onPaste: () => void;
|
|
||||||
onDownloadSelected: () => void;
|
|
||||||
onDeleteSelected: () => void;
|
|
||||||
onClearSelection: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Toolbar = ({
|
|
||||||
onRefresh,
|
|
||||||
onCreateDir,
|
|
||||||
onUpload,
|
|
||||||
selectionCount,
|
|
||||||
hasClipboard,
|
|
||||||
onCut,
|
|
||||||
onCopy,
|
|
||||||
onPaste,
|
|
||||||
onDownloadSelected,
|
|
||||||
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 = '';
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
const refreshBtn = (
|
|
||||||
<button
|
|
||||||
onClick={onRefresh}
|
|
||||||
title="Refresh"
|
|
||||||
className="p-1.5 rounded-md text-duck-dark/50 hover:bg-duck-dark/5 cursor-pointer transition-colors"
|
|
||||||
>
|
|
||||||
<RefreshCw className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (selectionCount > 0) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
{refreshBtn}
|
|
||||||
<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={onDownloadSelected}
|
|
||||||
title="Download"
|
|
||||||
className="p-1.5 rounded-md text-duck-dark/50 hover:bg-duck-dark/5 cursor-pointer transition-colors"
|
|
||||||
>
|
|
||||||
<Download 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">
|
|
||||||
{refreshBtn}
|
|
||||||
{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-background/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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,268 +0,0 @@
|
|||||||
import { useState, useEffect, useRef, useMemo } from 'react';
|
|
||||||
import { useNavigate } from 'react-router';
|
|
||||||
import { Folder, Pin, PinOff, Search, Clock, FolderOpen, Loader2, X } from 'lucide-react';
|
|
||||||
import { getIcon } from 'material-file-icons';
|
|
||||||
import { useFiles, type DirEntry } from './useFiles';
|
|
||||||
import { Breadcrumb } from './Breadcrumb';
|
|
||||||
import { useRecentFiles } from './useRecentFiles';
|
|
||||||
import { usePinnedFiles } from './usePinnedFiles';
|
|
||||||
import { Widget } from 'widgets/Widget';
|
|
||||||
|
|
||||||
type Tab = 'browse' | 'recent' | 'pinned';
|
|
||||||
|
|
||||||
const TABS: { key: Tab; label: string; icon: typeof Folder }[] = [
|
|
||||||
{ key: 'browse', label: 'Browse', icon: FolderOpen },
|
|
||||||
{ key: 'recent', label: 'Recent', icon: Clock },
|
|
||||||
{ key: 'pinned', label: 'Pinned', icon: Pin },
|
|
||||||
];
|
|
||||||
|
|
||||||
export const FileBrowser = () => {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const { listDir, search } = useFiles();
|
|
||||||
const { recents, addRecent } = useRecentFiles();
|
|
||||||
const { pinned, togglePin, isPinned } = usePinnedFiles();
|
|
||||||
|
|
||||||
const [tab, setTab] = useState<Tab>('browse');
|
|
||||||
const [browsePath, setBrowsePath] = useState('/');
|
|
||||||
const [entries, setEntries] = useState<DirEntry[]>([]);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
|
||||||
const [searchResults, setSearchResults] = useState<DirEntry[]>([]);
|
|
||||||
const [searching, setSearching] = useState(false);
|
|
||||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setLoading(true);
|
|
||||||
listDir(browsePath)
|
|
||||||
.then((res) => setEntries(res.entries))
|
|
||||||
.catch(() => setEntries([]))
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
}, [browsePath]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
|
||||||
const q = searchQuery.trim();
|
|
||||||
if (!q) {
|
|
||||||
setSearchResults([]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
debounceRef.current = setTimeout(() => {
|
|
||||||
setSearching(true);
|
|
||||||
search(q)
|
|
||||||
.then((res) => setSearchResults(res.results))
|
|
||||||
.catch(() => setSearchResults([]))
|
|
||||||
.finally(() => setSearching(false));
|
|
||||||
}, 300);
|
|
||||||
return () => {
|
|
||||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
|
||||||
};
|
|
||||||
}, [searchQuery]);
|
|
||||||
|
|
||||||
const openFile = (path: string, name: string) => {
|
|
||||||
addRecent(path, name);
|
|
||||||
navigate(`/files?view=${encodeURIComponent(path)}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
const isSearching = searchQuery.trim().length > 0;
|
|
||||||
|
|
||||||
const sorted = [...entries].sort((a, b) => {
|
|
||||||
if (a.type !== b.type) return a.type === 'directory' ? -1 : 1;
|
|
||||||
return a.name.localeCompare(b.name);
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Widget title="File Browser">
|
|
||||||
{/* Tab bar + search */}
|
|
||||||
<div className="flex items-center gap-2 px-4 pb-1">
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
{TABS.map(({ key, label, icon: Icon }) => (
|
|
||||||
<button
|
|
||||||
key={key}
|
|
||||||
onClick={() => setTab(key)}
|
|
||||||
className={`flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-colors cursor-pointer ${
|
|
||||||
!isSearching && tab === key
|
|
||||||
? 'bg-duck-teal/10 text-duck-teal'
|
|
||||||
: 'text-duck-dark/50 hover:text-duck-dark/70 hover:bg-duck-dark/5'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Icon className="h-3.5 w-3.5" />
|
|
||||||
{label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="relative ml-auto w-28 md:w-44">
|
|
||||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-duck-dark/30" />
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(ev) => setSearchQuery(ev.target.value)}
|
|
||||||
placeholder="Search..."
|
|
||||||
className="w-full rounded-lg border border-duck-dark/20 bg-background pl-8 pr-8 py-1 text-xs text-duck-dark placeholder:text-duck-dark/30 focus:outline-none focus:ring-2 focus:ring-duck-teal/30 focus:border-duck-teal/50"
|
|
||||||
/>
|
|
||||||
{searchQuery && (
|
|
||||||
<button
|
|
||||||
onClick={() => setSearchQuery('')}
|
|
||||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-duck-dark/30 hover:text-duck-dark/60 cursor-pointer"
|
|
||||||
>
|
|
||||||
<X className="h-3.5 w-3.5" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<div className="px-4 pb-3 max-h-72 overflow-y-auto">
|
|
||||||
{isSearching ? (
|
|
||||||
searching ? (
|
|
||||||
<div className="flex items-center justify-center py-6 text-duck-dark/40">
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
</div>
|
|
||||||
) : searchResults.length === 0 ? (
|
|
||||||
<p className="text-xs text-duck-dark/40 py-4 text-center">No results</p>
|
|
||||||
) : (
|
|
||||||
<ul className="space-y-0.5">
|
|
||||||
{searchResults.map((entry) => (
|
|
||||||
<EntryRow
|
|
||||||
key={entry.path}
|
|
||||||
name={entry.name}
|
|
||||||
subtitle={entry.path}
|
|
||||||
type={entry.type}
|
|
||||||
pinned={entry.type === 'file' && isPinned(entry.path!)}
|
|
||||||
onPin={entry.type === 'file' ? () => togglePin(entry.path!, entry.name) : undefined}
|
|
||||||
onClick={() => {
|
|
||||||
if (entry.type === 'file') openFile(entry.path!, entry.name);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{tab === 'browse' && (
|
|
||||||
<div>
|
|
||||||
<div className="py-2">
|
|
||||||
<Breadcrumb path={browsePath} onNavigate={setBrowsePath} />
|
|
||||||
</div>
|
|
||||||
{loading ? (
|
|
||||||
<div className="flex items-center justify-center py-6 text-duck-dark/40">
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
</div>
|
|
||||||
) : sorted.length === 0 ? (
|
|
||||||
<p className="text-xs text-duck-dark/40 py-4 text-center">Empty directory</p>
|
|
||||||
) : (
|
|
||||||
<ul className="space-y-0.5">
|
|
||||||
{sorted.map((entry) => {
|
|
||||||
const fullPath = browsePath === '/' ? `/${entry.name}` : `${browsePath}/${entry.name}`;
|
|
||||||
return (
|
|
||||||
<EntryRow
|
|
||||||
key={entry.name}
|
|
||||||
name={entry.name}
|
|
||||||
type={entry.type}
|
|
||||||
pinned={entry.type === 'file' && isPinned(fullPath)}
|
|
||||||
onPin={entry.type === 'file' ? () => togglePin(fullPath, entry.name) : undefined}
|
|
||||||
onClick={() => {
|
|
||||||
if (entry.type === 'directory') setBrowsePath(fullPath);
|
|
||||||
else openFile(fullPath, entry.name);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{tab === 'recent' && (
|
|
||||||
<div className="pt-2">
|
|
||||||
{recents.length === 0 ? (
|
|
||||||
<p className="text-xs text-duck-dark/40 py-4 text-center">No recent files</p>
|
|
||||||
) : (
|
|
||||||
<ul className="space-y-0.5">
|
|
||||||
{recents.map((f) => (
|
|
||||||
<EntryRow
|
|
||||||
key={f.path}
|
|
||||||
name={f.name}
|
|
||||||
subtitle={f.path}
|
|
||||||
type="file"
|
|
||||||
pinned={isPinned(f.path)}
|
|
||||||
onPin={() => togglePin(f.path, f.name)}
|
|
||||||
onClick={() => openFile(f.path, f.name)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{tab === 'pinned' && (
|
|
||||||
<div className="pt-2">
|
|
||||||
{pinned.length === 0 ? (
|
|
||||||
<p className="text-xs text-duck-dark/40 py-4 text-center">No pinned files</p>
|
|
||||||
) : (
|
|
||||||
<ul className="space-y-0.5">
|
|
||||||
{pinned.map((f) => (
|
|
||||||
<EntryRow
|
|
||||||
key={f.path}
|
|
||||||
name={f.name}
|
|
||||||
subtitle={f.path}
|
|
||||||
type="file"
|
|
||||||
pinned
|
|
||||||
onPin={() => togglePin(f.path, f.name)}
|
|
||||||
onClick={() => openFile(f.path, f.name)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Widget>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
type EntryRowProps = {
|
|
||||||
name: string;
|
|
||||||
subtitle?: string;
|
|
||||||
type: 'file' | 'directory';
|
|
||||||
pinned?: boolean;
|
|
||||||
onPin?: () => void;
|
|
||||||
onClick: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const MaterialFileIcon = ({ name, className }: { name: string; className?: string }) => {
|
|
||||||
const svg = useMemo(() => getIcon(name).svg, [name]);
|
|
||||||
return <span className={className} dangerouslySetInnerHTML={{ __html: svg }} />;
|
|
||||||
};
|
|
||||||
|
|
||||||
const EntryRow = ({ name, subtitle, type, pinned, onPin, onClick }: EntryRowProps) => (
|
|
||||||
<li className="flex items-center gap-2 px-2 py-1.5 rounded-md hover:bg-duck-dark/5 group">
|
|
||||||
<button onClick={onClick} className="flex items-center gap-2 flex-1 min-w-0 text-left cursor-pointer">
|
|
||||||
{type === 'directory' ? (
|
|
||||||
<Folder className="h-4 w-4 shrink-0 text-duck-teal" />
|
|
||||||
) : (
|
|
||||||
<MaterialFileIcon name={name} className="inline-flex h-4 w-4 shrink-0" />
|
|
||||||
)}
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<span className="text-sm text-duck-dark truncate block">{name}</span>
|
|
||||||
{subtitle && <span className="text-xs text-duck-dark/40 truncate block">{subtitle}</span>}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
{onPin && (
|
|
||||||
<button
|
|
||||||
onClick={(ev) => {
|
|
||||||
ev.stopPropagation();
|
|
||||||
onPin();
|
|
||||||
}}
|
|
||||||
className={`shrink-0 p-1 rounded transition-colors cursor-pointer ${
|
|
||||||
pinned
|
|
||||||
? 'text-duck-teal hover:text-duck-teal/70'
|
|
||||||
: 'text-duck-dark/20 opacity-0 group-hover:opacity-100 hover:text-duck-dark/50'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{pinned ? <PinOff className="h-3.5 w-3.5" /> : <Pin className="h-3.5 w-3.5" />}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
export { Breadcrumb } from './Breadcrumb';
|
|
||||||
export { Toolbar } from './Toolbar';
|
|
||||||
export { useFiles, type DirEntry } from './useFiles';
|
|
||||||
export { useTasks, type TaskSummary } from './useTasks';
|
|
||||||
export { Files } from './Files';
|
|
||||||
export { FileBrowser } from './Widget';
|
|
||||||
export { FileGrid } from './FileGrid';
|
|
||||||
export { FileItem, type FileItemProps } from './FileItem';
|
|
||||||
export { TaskRunnerModal } from './TaskRunnerModal';
|
|
||||||
export { useRecentFiles } from './useRecentFiles';
|
|
||||||
export { usePinnedFiles } from './usePinnedFiles';
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import { useCallback } from 'react';
|
|
||||||
import { useUserState } from '@/state/useUserState';
|
|
||||||
|
|
||||||
type PinnedFile = { path: string; name: string; pinnedAt: number };
|
|
||||||
|
|
||||||
export const usePinnedFiles = () => {
|
|
||||||
const [pinned, setPinned] = useUserState<PinnedFile[]>('pinnedFiles', []);
|
|
||||||
|
|
||||||
const togglePin = useCallback(
|
|
||||||
(path: string, name: string) => {
|
|
||||||
setPinned((prev) => {
|
|
||||||
const exists = prev.some((f) => f.path === path);
|
|
||||||
if (exists) return prev.filter((f) => f.path !== path);
|
|
||||||
return [{ path, name, pinnedAt: Date.now() }, ...prev];
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[setPinned],
|
|
||||||
);
|
|
||||||
|
|
||||||
const isPinned = useCallback((path: string) => pinned.some((f) => f.path === path), [pinned]);
|
|
||||||
|
|
||||||
return { pinned, togglePin, isPinned };
|
|
||||||
};
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
import { useCallback } from 'react';
|
|
||||||
import { useUserState } from '@/state/useUserState';
|
|
||||||
|
|
||||||
type RecentFile = { path: string; name: string; openedAt: number };
|
|
||||||
|
|
||||||
const MAX_RECENTS = 20;
|
|
||||||
|
|
||||||
export const useRecentFiles = () => {
|
|
||||||
const [recents, setRecents] = useUserState<RecentFile[]>('recentFiles', []);
|
|
||||||
|
|
||||||
const addRecent = useCallback(
|
|
||||||
(path: string, name: string) => {
|
|
||||||
setRecents((prev) => {
|
|
||||||
const filtered = prev.filter((f) => f.path !== path);
|
|
||||||
return [{ path, name, openedAt: Date.now() }, ...filtered].slice(0, MAX_RECENTS);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[setRecents],
|
|
||||||
);
|
|
||||||
|
|
||||||
return { recents, addRecent };
|
|
||||||
};
|
|
||||||
@@ -1,358 +0,0 @@
|
|||||||
import { createContext, useContext, useState, useEffect, useRef, useCallback } from 'react';
|
|
||||||
import type { ReactNode } from 'react';
|
|
||||||
import {
|
|
||||||
Download,
|
|
||||||
Loader2,
|
|
||||||
Music,
|
|
||||||
Film,
|
|
||||||
Image,
|
|
||||||
FileType2,
|
|
||||||
Volume2,
|
|
||||||
ArrowUp,
|
|
||||||
ScanText,
|
|
||||||
FileText,
|
|
||||||
AudioLines,
|
|
||||||
FolderArchive,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { useFiles } from 'apps/FileBrowser';
|
|
||||||
import { useClient } from 'hooks/useClient';
|
|
||||||
import { toast } from 'sonner';
|
|
||||||
import { getIcon } from 'material-file-icons';
|
|
||||||
import { getFileType, getLang, getRawUrl, getTranscodeUrl, needsTranscode, getArchiveBaseName } from './file-types';
|
|
||||||
import type { FileType } from './file-types';
|
|
||||||
import {
|
|
||||||
PdfRenderer,
|
|
||||||
ImageRenderer,
|
|
||||||
VideoRenderer,
|
|
||||||
AudioRenderer,
|
|
||||||
CodeRenderer,
|
|
||||||
MarkdownRenderer,
|
|
||||||
TextRenderer,
|
|
||||||
ScrollToTopButton,
|
|
||||||
} from './FileViewerView';
|
|
||||||
|
|
||||||
// ── Context ──
|
|
||||||
|
|
||||||
type FileViewerContextValue = {
|
|
||||||
filePath: string;
|
|
||||||
fileName: string;
|
|
||||||
root: string;
|
|
||||||
fileType: FileType;
|
|
||||||
content: string | null;
|
|
||||||
loading: boolean;
|
|
||||||
error: string | null;
|
|
||||||
directContent: boolean;
|
|
||||||
ttsLoading: boolean;
|
|
||||||
ocrLoading: boolean;
|
|
||||||
transcribeLoading: boolean;
|
|
||||||
extractAudioLoading: boolean;
|
|
||||||
extractLoading: boolean;
|
|
||||||
autoPlay: boolean;
|
|
||||||
handleReadAloud: () => void;
|
|
||||||
handleOcr: () => void;
|
|
||||||
handleTranscribe: () => void;
|
|
||||||
handleExtractAudio: () => void;
|
|
||||||
handleExtract: () => void;
|
|
||||||
handleDownload: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const FileViewerContext = createContext<FileViewerContextValue | null>(null);
|
|
||||||
|
|
||||||
const useFileViewer = () => {
|
|
||||||
const ctx = useContext(FileViewerContext);
|
|
||||||
if (!ctx) throw new Error('useFileViewer must be used within FileViewerProvider');
|
|
||||||
return ctx;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Provider ──
|
|
||||||
|
|
||||||
type FileViewerProviderProps = {
|
|
||||||
filePath: string;
|
|
||||||
fileName: string;
|
|
||||||
root?: string;
|
|
||||||
content?: string;
|
|
||||||
onOpenFile?: (filePath: string, root: string) => void;
|
|
||||||
autoPlay?: boolean;
|
|
||||||
children: ReactNode;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const FileViewerProvider = ({ filePath, fileName, root = 'home', content: directContent, onOpenFile, autoPlay = false, children }: FileViewerProviderProps) => {
|
|
||||||
const [content, setContent] = useState<string | null>(directContent ?? null);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [ttsLoading, setTtsLoading] = useState(false);
|
|
||||||
const [ocrLoading, setOcrLoading] = useState(false);
|
|
||||||
const [transcribeLoading, setTranscribeLoading] = useState(false);
|
|
||||||
const [extractAudioLoading, setExtractAudioLoading] = useState(false);
|
|
||||||
const [extractLoading, setExtractLoading] = useState(false);
|
|
||||||
const client = useClient();
|
|
||||||
const files = useFiles(root);
|
|
||||||
const fileType = getFileType(fileName);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (directContent !== undefined) {
|
|
||||||
setContent(directContent);
|
|
||||||
setLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fileType === 'audio' || fileType === 'video' || fileType === 'image' || fileType === 'pdf' || fileType === 'archive') {
|
|
||||||
setLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
files
|
|
||||||
.readFile(filePath)
|
|
||||||
.then((res) => setContent(res.content))
|
|
||||||
.catch(() => setError('Failed to read file'))
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
}, [filePath, directContent]);
|
|
||||||
|
|
||||||
const handleDownload = useCallback(() => {
|
|
||||||
const url = getRawUrl(filePath, root);
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url;
|
|
||||||
a.download = fileName;
|
|
||||||
a.click();
|
|
||||||
}, [filePath, root, fileName]);
|
|
||||||
|
|
||||||
const handleReadAloud = useCallback(async () => {
|
|
||||||
setTtsLoading(true);
|
|
||||||
try {
|
|
||||||
const { audioPath, audioRoot } = await client.post<{ audioPath: string; audioRoot: string }>('/file-browser/tts', { path: filePath, root });
|
|
||||||
onOpenFile?.(audioPath, audioRoot);
|
|
||||||
} catch {
|
|
||||||
toast.error('Failed to generate speech audio');
|
|
||||||
} finally {
|
|
||||||
setTtsLoading(false);
|
|
||||||
}
|
|
||||||
}, [filePath, root, client, onOpenFile]);
|
|
||||||
|
|
||||||
const handleOcr = useCallback(async () => {
|
|
||||||
setOcrLoading(true);
|
|
||||||
try {
|
|
||||||
const { ocrPath, ocrRoot } = await client.post<{ ocrPath: string; ocrRoot: string }>('/file-browser/ocr', { path: filePath, root });
|
|
||||||
onOpenFile?.(ocrPath, ocrRoot);
|
|
||||||
} catch {
|
|
||||||
toast.error('Failed to extract text from image');
|
|
||||||
} finally {
|
|
||||||
setOcrLoading(false);
|
|
||||||
}
|
|
||||||
}, [filePath, root, client, onOpenFile]);
|
|
||||||
|
|
||||||
const handleTranscribe = useCallback(async () => {
|
|
||||||
setTranscribeLoading(true);
|
|
||||||
try {
|
|
||||||
const { transcriptionPath, transcriptionRoot } = await client.post<{ transcriptionPath: string; transcriptionRoot: string }>('/file-browser/transcribe', { path: filePath, root });
|
|
||||||
onOpenFile?.(transcriptionPath, transcriptionRoot);
|
|
||||||
} catch {
|
|
||||||
toast.error('Failed to transcribe audio');
|
|
||||||
} finally {
|
|
||||||
setTranscribeLoading(false);
|
|
||||||
}
|
|
||||||
}, [filePath, root, client, onOpenFile]);
|
|
||||||
|
|
||||||
const handleExtractAudio = useCallback(async () => {
|
|
||||||
setExtractAudioLoading(true);
|
|
||||||
try {
|
|
||||||
const { audioPath, audioRoot } = await client.post<{ audioPath: string; audioRoot: string }>('/file-browser/extract-audio', { path: filePath, root });
|
|
||||||
onOpenFile?.(audioPath, audioRoot);
|
|
||||||
} catch {
|
|
||||||
toast.error('Failed to extract audio from video');
|
|
||||||
} finally {
|
|
||||||
setExtractAudioLoading(false);
|
|
||||||
}
|
|
||||||
}, [filePath, root, client, onOpenFile]);
|
|
||||||
|
|
||||||
const handleExtract = useCallback(async () => {
|
|
||||||
setExtractLoading(true);
|
|
||||||
try {
|
|
||||||
const { extractedPath } = await client.post<{ extractedPath: string }>('/file-browser/extract', { path: filePath, root });
|
|
||||||
const folderName = extractedPath.split('/').pop() ?? extractedPath;
|
|
||||||
toast.success(`Extracted to "${folderName}"`);
|
|
||||||
} catch {
|
|
||||||
toast.error('Failed to extract archive');
|
|
||||||
} finally {
|
|
||||||
setExtractLoading(false);
|
|
||||||
}
|
|
||||||
}, [filePath, root, client]);
|
|
||||||
|
|
||||||
const value: FileViewerContextValue = {
|
|
||||||
filePath,
|
|
||||||
fileName,
|
|
||||||
root,
|
|
||||||
fileType,
|
|
||||||
content,
|
|
||||||
loading,
|
|
||||||
error,
|
|
||||||
directContent: directContent !== undefined,
|
|
||||||
ttsLoading,
|
|
||||||
ocrLoading,
|
|
||||||
transcribeLoading,
|
|
||||||
extractAudioLoading,
|
|
||||||
extractLoading,
|
|
||||||
autoPlay,
|
|
||||||
handleReadAloud,
|
|
||||||
handleOcr,
|
|
||||||
handleTranscribe,
|
|
||||||
handleExtractAudio,
|
|
||||||
handleExtract,
|
|
||||||
handleDownload,
|
|
||||||
};
|
|
||||||
|
|
||||||
return <FileViewerContext value={value}>{children}</FileViewerContext>;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Header (content fragments only — no container div) ──
|
|
||||||
|
|
||||||
export const FileViewerHeader = () => {
|
|
||||||
const { fileName, fileType, content, directContent, ttsLoading, ocrLoading, transcribeLoading, extractAudioLoading, extractLoading, handleReadAloud, handleOcr, handleTranscribe, handleExtractAudio, handleExtract, handleDownload } =
|
|
||||||
useFileViewer();
|
|
||||||
|
|
||||||
const textContent = content;
|
|
||||||
const showDownload = !directContent;
|
|
||||||
|
|
||||||
const headerIcon =
|
|
||||||
fileType === 'audio' ? (
|
|
||||||
<Music className="h-4 w-4 text-duck-teal shrink-0" />
|
|
||||||
) : fileType === 'video' ? (
|
|
||||||
<Film className="h-4 w-4 text-duck-orange shrink-0" />
|
|
||||||
) : fileType === 'image' ? (
|
|
||||||
<Image className="h-4 w-4 text-duck-yellow shrink-0" />
|
|
||||||
) : fileType === 'pdf' ? (
|
|
||||||
<FileType2 className="h-4 w-4 text-red-500 shrink-0" />
|
|
||||||
) : fileType === 'archive' ? (
|
|
||||||
<FolderArchive className="h-4 w-4 text-duck-orange shrink-0" />
|
|
||||||
) : (
|
|
||||||
<span className="inline-flex h-4 w-4 shrink-0" dangerouslySetInnerHTML={{ __html: getIcon(fileName).svg }} />
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{headerIcon}
|
|
||||||
<span className="text-xs font-medium truncate flex-1">{fileName}</span>
|
|
||||||
<span className="text-[10px] font-mono uppercase tracking-wider shrink-0 opacity-60">
|
|
||||||
{fileType === 'code' ? getLang(fileName) : fileType}
|
|
||||||
</span>
|
|
||||||
{(fileType === 'markdown' || fileType === 'code' || fileType === 'text') && textContent && (
|
|
||||||
<button
|
|
||||||
onClick={handleReadAloud}
|
|
||||||
disabled={ttsLoading}
|
|
||||||
className="p-1 rounded hover:bg-current/10 disabled:opacity-40 transition-colors cursor-pointer"
|
|
||||||
title="Read Aloud"
|
|
||||||
>
|
|
||||||
{ttsLoading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Volume2 className="h-3.5 w-3.5" />}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{fileType === 'image' && (
|
|
||||||
<button
|
|
||||||
onClick={handleOcr}
|
|
||||||
disabled={ocrLoading}
|
|
||||||
className="p-1 rounded hover:bg-current/10 disabled:opacity-40 transition-colors cursor-pointer"
|
|
||||||
title="Extract Text (OCR)"
|
|
||||||
>
|
|
||||||
{ocrLoading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <ScanText className="h-3.5 w-3.5" />}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{fileType === 'audio' && (
|
|
||||||
<button
|
|
||||||
onClick={handleTranscribe}
|
|
||||||
disabled={transcribeLoading}
|
|
||||||
className="p-1 rounded hover:bg-current/10 disabled:opacity-40 transition-colors cursor-pointer"
|
|
||||||
title="Transcribe"
|
|
||||||
>
|
|
||||||
{transcribeLoading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <FileText className="h-3.5 w-3.5" />}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{fileType === 'video' && (
|
|
||||||
<button
|
|
||||||
onClick={handleExtractAudio}
|
|
||||||
disabled={extractAudioLoading}
|
|
||||||
className="p-1 rounded hover:bg-current/10 disabled:opacity-40 transition-colors cursor-pointer"
|
|
||||||
title="Extract Audio"
|
|
||||||
>
|
|
||||||
{extractAudioLoading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <AudioLines className="h-3.5 w-3.5" />}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{fileType === 'archive' && (
|
|
||||||
<button
|
|
||||||
onClick={handleExtract}
|
|
||||||
disabled={extractLoading}
|
|
||||||
className="p-1 rounded hover:bg-current/10 disabled:opacity-40 transition-colors cursor-pointer"
|
|
||||||
title="Extract"
|
|
||||||
>
|
|
||||||
{extractLoading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <FolderArchive className="h-3.5 w-3.5" />}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{showDownload && (
|
|
||||||
<button
|
|
||||||
onClick={handleDownload}
|
|
||||||
className="p-1 rounded hover:bg-current/10 transition-colors cursor-pointer"
|
|
||||||
title="Download"
|
|
||||||
>
|
|
||||||
<Download className="h-3.5 w-3.5" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Body (renderers) ──
|
|
||||||
|
|
||||||
export const FileViewerBody = () => {
|
|
||||||
const { filePath, fileName, root, fileType, content, loading, error, autoPlay } = useFileViewer();
|
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
const videoSrc =
|
|
||||||
fileType === 'video'
|
|
||||||
? needsTranscode(fileName)
|
|
||||||
? getTranscodeUrl(filePath, root)
|
|
||||||
: getRawUrl(filePath, root)
|
|
||||||
: '';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div ref={scrollRef} className="h-full overflow-auto">
|
|
||||||
{loading ? (
|
|
||||||
<div className="flex items-center justify-center h-full">
|
|
||||||
<Loader2 className="h-6 w-6 text-duck-teal animate-spin" />
|
|
||||||
</div>
|
|
||||||
) : error ? (
|
|
||||||
<div className="flex items-center justify-center h-full">
|
|
||||||
<span className="text-sm text-red-500">{error}</span>
|
|
||||||
</div>
|
|
||||||
) : fileType === 'pdf' ? (
|
|
||||||
<PdfRenderer src={getRawUrl(filePath, root)} />
|
|
||||||
) : fileType === 'archive' ? (
|
|
||||||
<div className="flex flex-col items-center justify-center h-full gap-3 text-duck-dark/50">
|
|
||||||
<FolderArchive className="h-10 w-10" />
|
|
||||||
<span className="text-sm">Archive file</span>
|
|
||||||
<span className="text-xs">{getArchiveBaseName(fileName)}</span>
|
|
||||||
</div>
|
|
||||||
) : fileType === 'image' ? (
|
|
||||||
<ImageRenderer src={getRawUrl(filePath, root)} fileName={fileName} />
|
|
||||||
) : fileType === 'video' ? (
|
|
||||||
<VideoRenderer src={videoSrc} fileName={fileName} />
|
|
||||||
) : fileType === 'audio' ? (
|
|
||||||
<AudioRenderer src={getRawUrl(filePath, root)} fileName={fileName} autoPlay={autoPlay} />
|
|
||||||
) : content !== null ? (
|
|
||||||
fileType === 'code' ? (
|
|
||||||
<div>
|
|
||||||
<CodeRenderer content={content} lang={getLang(fileName)} />
|
|
||||||
<ScrollToTopButton scrollContainer={scrollRef} />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="px-8 py-6">
|
|
||||||
{fileType === 'markdown' ? (
|
|
||||||
<MarkdownRenderer content={content} scrollContainer={scrollRef} />
|
|
||||||
) : (
|
|
||||||
<TextRenderer content={content} />
|
|
||||||
)}
|
|
||||||
<ScrollToTopButton scrollContainer={scrollRef} />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,774 +0,0 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
|
||||||
import {
|
|
||||||
X,
|
|
||||||
Loader2,
|
|
||||||
Music,
|
|
||||||
Maximize2,
|
|
||||||
Minimize2,
|
|
||||||
Play,
|
|
||||||
Pause,
|
|
||||||
Volume2,
|
|
||||||
VolumeX,
|
|
||||||
ZoomIn,
|
|
||||||
ZoomOut,
|
|
||||||
RotateCw,
|
|
||||||
ArrowUp,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import ReactMarkdown from 'react-markdown';
|
|
||||||
import remarkGfm from 'remark-gfm';
|
|
||||||
import rehypeRaw from 'rehype-raw';
|
|
||||||
import rehypeSlug from 'rehype-slug';
|
|
||||||
import { getExt, formatTime } from './file-types';
|
|
||||||
import { FileViewerProvider, FileViewerHeader, FileViewerBody } from './FileViewerContext';
|
|
||||||
|
|
||||||
// ── Shared seek bar hook ──
|
|
||||||
function useSeekBar(mediaRef: React.RefObject<HTMLMediaElement | null>, duration: number) {
|
|
||||||
const barRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
const seekTo = useCallback(
|
|
||||||
(clientX: number) => {
|
|
||||||
const el = mediaRef.current;
|
|
||||||
const bar = barRef.current;
|
|
||||||
if (!el || !bar || !duration) return;
|
|
||||||
const rect = bar.getBoundingClientRect();
|
|
||||||
const pct = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
|
|
||||||
el.currentTime = pct * duration;
|
|
||||||
},
|
|
||||||
[duration, mediaRef],
|
|
||||||
);
|
|
||||||
|
|
||||||
const onSeekDown = useCallback(
|
|
||||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
|
||||||
e.preventDefault();
|
|
||||||
seekTo(e.clientX);
|
|
||||||
const onMove = (ev: MouseEvent) => seekTo(ev.clientX);
|
|
||||||
const onUp = () => {
|
|
||||||
window.removeEventListener('mousemove', onMove);
|
|
||||||
window.removeEventListener('mouseup', onUp);
|
|
||||||
};
|
|
||||||
window.addEventListener('mousemove', onMove);
|
|
||||||
window.addEventListener('mouseup', onUp);
|
|
||||||
},
|
|
||||||
[seekTo],
|
|
||||||
);
|
|
||||||
|
|
||||||
return { barRef, onSeekDown };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Seek bar component ──
|
|
||||||
function SeekBar({
|
|
||||||
barRef,
|
|
||||||
onSeekDown,
|
|
||||||
pct,
|
|
||||||
trackClass = 'bg-duck-dark/10',
|
|
||||||
fillClass = 'bg-duck-teal',
|
|
||||||
thumbClass = 'bg-duck-teal border-white',
|
|
||||||
}: {
|
|
||||||
barRef: React.RefObject<HTMLDivElement | null>;
|
|
||||||
onSeekDown: (e: React.MouseEvent<HTMLDivElement>) => void;
|
|
||||||
pct: number;
|
|
||||||
trackClass?: string;
|
|
||||||
fillClass?: string;
|
|
||||||
thumbClass?: string;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div ref={barRef} onMouseDown={onSeekDown} className="relative h-4 flex items-center cursor-pointer group">
|
|
||||||
<div className={`absolute left-0 right-0 h-1.5 rounded-full ${trackClass} pointer-events-none`}>
|
|
||||||
<div className={`absolute inset-y-0 left-0 rounded-full ${fillClass}`} style={{ width: `${pct}%` }} />
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={`absolute top-1/2 -translate-y-1/2 w-3.5 h-3.5 rounded-full shadow-md border-2 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none ${thumbClass} ${fillClass}`}
|
|
||||||
style={{ left: `calc(${pct}% - 7px)` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Copy button for code blocks ──
|
|
||||||
function CopyButton({ text }: { text: string }) {
|
|
||||||
const [copied, setCopied] = useState(false);
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
navigator.clipboard.writeText(text);
|
|
||||||
setCopied(true);
|
|
||||||
setTimeout(() => setCopied(false), 1500);
|
|
||||||
}}
|
|
||||||
className="absolute top-2 right-2 px-2 py-1 text-[10px] font-mono rounded bg-white/10 text-white/60 hover:text-white hover:bg-white/20 transition-colors cursor-pointer"
|
|
||||||
>
|
|
||||||
{copied ? 'Copied!' : 'Copy'}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Highlighted code block for markdown ──
|
|
||||||
function HighlightedCodeBlock({ code, lang }: { code: string; lang: string }) {
|
|
||||||
const [html, setHtml] = useState<string | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let cancelled = false;
|
|
||||||
import('shiki')
|
|
||||||
.then(({ codeToHtml }) => codeToHtml(code, { lang, theme: 'github-dark-default' }))
|
|
||||||
.then((result) => {
|
|
||||||
if (!cancelled) setHtml(result);
|
|
||||||
})
|
|
||||||
.catch(() => {});
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [code, lang]);
|
|
||||||
|
|
||||||
if (html) {
|
|
||||||
return (
|
|
||||||
<div className="relative my-4">
|
|
||||||
<CopyButton text={code} />
|
|
||||||
{lang && (
|
|
||||||
<span className="absolute top-2 left-3 text-[10px] font-mono text-white/30 uppercase tracking-wider z-10">
|
|
||||||
{lang}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<div
|
|
||||||
className="[&_pre]:rounded-lg [&_pre]:p-4 [&_pre]:pt-8 [&_pre]:overflow-x-auto [&_pre]:text-sm [&_pre]:font-mono [&_pre]:leading-relaxed [&_pre]:border [&_pre]:border-white/5 [&_code]:font-mono"
|
|
||||||
dangerouslySetInnerHTML={{ __html: html }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<pre className="relative rounded-lg bg-[#0d1117] text-[#e6edf3] p-4 overflow-x-auto text-sm font-mono leading-relaxed my-4 border border-white/5">
|
|
||||||
<CopyButton text={code} />
|
|
||||||
{lang && (
|
|
||||||
<span className="absolute top-2 left-3 text-[10px] font-mono text-white/30 uppercase tracking-wider">
|
|
||||||
{lang}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<code className="block pt-4">{code}</code>
|
|
||||||
</pre>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Scroll to top button ──
|
|
||||||
export function ScrollToTopButton({ scrollContainer }: { scrollContainer: React.RefObject<HTMLDivElement | null> }) {
|
|
||||||
const [visible, setVisible] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const el = scrollContainer.current;
|
|
||||||
if (!el) return;
|
|
||||||
const onScroll = () => setVisible(el.scrollTop > 200);
|
|
||||||
el.addEventListener('scroll', onScroll, { passive: true });
|
|
||||||
return () => el.removeEventListener('scroll', onScroll);
|
|
||||||
}, [scrollContainer]);
|
|
||||||
|
|
||||||
if (!visible) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
onClick={() => scrollContainer.current?.scrollTo({ top: 0, behavior: 'smooth' })}
|
|
||||||
className="sticky bottom-4 float-right mr-4 z-10 p-2.5 rounded-full bg-duck-teal text-white shadow-lg hover:bg-duck-teal/90 active:scale-95 transition-all cursor-pointer"
|
|
||||||
title="Scroll to top"
|
|
||||||
>
|
|
||||||
<ArrowUp className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Markdown renderer ──
|
|
||||||
export const MarkdownRenderer = ({
|
|
||||||
content,
|
|
||||||
scrollContainer,
|
|
||||||
}: {
|
|
||||||
content: string;
|
|
||||||
scrollContainer: React.RefObject<HTMLDivElement | null>;
|
|
||||||
}) => {
|
|
||||||
const handleAnchorClick = useCallback(
|
|
||||||
(ev: React.MouseEvent<HTMLElement>) => {
|
|
||||||
const target = (ev.target as HTMLElement).closest('a');
|
|
||||||
if (!target) return;
|
|
||||||
const href = target.getAttribute('href');
|
|
||||||
if (!href?.startsWith('#')) return;
|
|
||||||
ev.preventDefault();
|
|
||||||
const id = href.slice(1);
|
|
||||||
const el = scrollContainer.current?.querySelector(`#${CSS.escape(id)}`);
|
|
||||||
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
||||||
},
|
|
||||||
[scrollContainer],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<article className="file-viewer-md" onClick={handleAnchorClick}>
|
|
||||||
<ReactMarkdown
|
|
||||||
remarkPlugins={[remarkGfm]}
|
|
||||||
rehypePlugins={[rehypeRaw, rehypeSlug]}
|
|
||||||
components={{
|
|
||||||
pre({ children }) {
|
|
||||||
return <div className="relative">{children}</div>;
|
|
||||||
},
|
|
||||||
code({ className, children, ...props }) {
|
|
||||||
const isBlock = className?.startsWith('language-');
|
|
||||||
const lang = className?.replace('language-', '') ?? '';
|
|
||||||
const text = String(children).replace(/\n$/, '');
|
|
||||||
|
|
||||||
if (!isBlock) {
|
|
||||||
return (
|
|
||||||
<code
|
|
||||||
className="px-1.5 py-0.5 rounded bg-duck-teal/10 text-duck-teal text-[0.85em] font-mono"
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</code>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return <HighlightedCodeBlock code={text} lang={lang} />;
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{content}
|
|
||||||
</ReactMarkdown>
|
|
||||||
</article>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Text renderer ──
|
|
||||||
export const TextRenderer = ({ content }: { content: string }) => (
|
|
||||||
<pre className="whitespace-pre-wrap font-mono text-sm text-foreground leading-relaxed p-4">{content}</pre>
|
|
||||||
);
|
|
||||||
|
|
||||||
// ── Code renderer with syntax highlighting ──
|
|
||||||
export const CodeRenderer = ({ content, lang }: { content: string; lang: string }) => {
|
|
||||||
const [html, setHtml] = useState<string | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let cancelled = false;
|
|
||||||
import('shiki')
|
|
||||||
.then(({ codeToHtml }) => codeToHtml(content, { lang, theme: 'github-dark-default' }))
|
|
||||||
.then((result) => {
|
|
||||||
if (!cancelled) setHtml(result);
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
if (!cancelled) setHtml(null);
|
|
||||||
});
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [content, lang]);
|
|
||||||
|
|
||||||
if (html === null) {
|
|
||||||
return <TextRenderer content={content} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="code-highlight text-sm leading-relaxed [&_pre]:p-4 [&_pre]:overflow-x-auto [&_pre]:rounded-none [&_code]:font-mono"
|
|
||||||
dangerouslySetInnerHTML={{ __html: html }}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Audio renderer ──
|
|
||||||
export const AudioRenderer = ({ src, fileName, autoPlay = false }: { src: string; fileName: string; autoPlay?: boolean }) => {
|
|
||||||
const audioRef = useRef<HTMLAudioElement>(null);
|
|
||||||
const [playing, setPlaying] = useState(false);
|
|
||||||
const [currentTime, setCurrent] = useState(0);
|
|
||||||
const [duration, setDuration] = useState(0);
|
|
||||||
const [volume, setVolume] = useState(1);
|
|
||||||
const [muted, setMuted] = useState(false);
|
|
||||||
const [loaded, setLoaded] = useState(false);
|
|
||||||
const [error, setError] = useState(false);
|
|
||||||
const { barRef, onSeekDown } = useSeekBar(audioRef, duration);
|
|
||||||
|
|
||||||
const ext = fileName.split('.').pop()?.toUpperCase() ?? 'AUDIO';
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const a = audioRef.current;
|
|
||||||
if (!a) return;
|
|
||||||
const onLoaded = () => {
|
|
||||||
setDuration(a.duration);
|
|
||||||
setLoaded(true);
|
|
||||||
if (autoPlay) {
|
|
||||||
a.play();
|
|
||||||
setPlaying(true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const onTime = () => setCurrent(a.currentTime);
|
|
||||||
const onEnded = () => setPlaying(false);
|
|
||||||
const onError = () => setError(true);
|
|
||||||
a.addEventListener('loadedmetadata', onLoaded);
|
|
||||||
a.addEventListener('timeupdate', onTime);
|
|
||||||
a.addEventListener('ended', onEnded);
|
|
||||||
a.addEventListener('error', onError);
|
|
||||||
return () => {
|
|
||||||
a.removeEventListener('loadedmetadata', onLoaded);
|
|
||||||
a.removeEventListener('timeupdate', onTime);
|
|
||||||
a.removeEventListener('ended', onEnded);
|
|
||||||
a.removeEventListener('error', onError);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const togglePlay = useCallback(() => {
|
|
||||||
const a = audioRef.current;
|
|
||||||
if (!a) return;
|
|
||||||
if (playing) a.pause();
|
|
||||||
else a.play();
|
|
||||||
setPlaying(!playing);
|
|
||||||
}, [playing]);
|
|
||||||
|
|
||||||
const changeVolume = useCallback((ev: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const v = parseFloat(ev.target.value);
|
|
||||||
setVolume(v);
|
|
||||||
setMuted(v === 0);
|
|
||||||
if (audioRef.current) audioRef.current.volume = v;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const toggleMute = useCallback(() => {
|
|
||||||
const a = audioRef.current;
|
|
||||||
if (!a) return;
|
|
||||||
if (muted) {
|
|
||||||
a.volume = volume || 1;
|
|
||||||
setMuted(false);
|
|
||||||
} else {
|
|
||||||
a.volume = 0;
|
|
||||||
setMuted(true);
|
|
||||||
}
|
|
||||||
}, [muted, volume]);
|
|
||||||
|
|
||||||
const pct = duration > 0 ? (currentTime / duration) * 100 : 0;
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center h-full">
|
|
||||||
<span className="text-sm text-red-500">Failed to load audio file</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center h-full px-8">
|
|
||||||
<audio ref={audioRef} src={src} preload="metadata" />
|
|
||||||
<div className="w-full max-w-xl space-y-6">
|
|
||||||
<div className="flex flex-col items-center gap-4">
|
|
||||||
<div className="relative w-32 h-32 rounded-2xl bg-gradient-to-br from-duck-teal/20 via-duck-forest/10 to-duck-yellow/20 border-2 border-duck-dark/10 flex items-center justify-center shadow-lg">
|
|
||||||
<Music className="h-12 w-12 text-duck-teal/60" />
|
|
||||||
<span className="absolute bottom-2 right-2 text-[9px] font-mono font-bold text-duck-dark/30 tracking-wider">
|
|
||||||
{ext}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-center">
|
|
||||||
<p className="text-sm font-semibold text-duck-dark truncate max-w-xs">{fileName}</p>
|
|
||||||
{loaded && <p className="text-xs text-duck-dark/40 mt-0.5">{formatTime(duration)}</p>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<SeekBar barRef={barRef} onSeekDown={onSeekDown} pct={pct} />
|
|
||||||
<div className="flex justify-between text-[10px] font-mono text-duck-dark/40">
|
|
||||||
<span>{formatTime(currentTime)}</span>
|
|
||||||
<span>{formatTime(duration)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-center gap-6">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
onClick={toggleMute}
|
|
||||||
className="p-1.5 text-duck-dark/40 hover:text-duck-dark transition-colors cursor-pointer"
|
|
||||||
>
|
|
||||||
{muted ? <VolumeX className="h-4 w-4" /> : <Volume2 className="h-4 w-4" />}
|
|
||||||
</button>
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min={0}
|
|
||||||
max={1}
|
|
||||||
step={0.01}
|
|
||||||
value={muted ? 0 : volume}
|
|
||||||
onChange={changeVolume}
|
|
||||||
className="w-20 h-1 accent-duck-teal cursor-pointer"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={togglePlay}
|
|
||||||
disabled={!loaded}
|
|
||||||
className="w-14 h-14 rounded-full bg-duck-teal text-white flex items-center justify-center shadow-lg hover:bg-duck-teal/90 disabled:opacity-40 transition-all cursor-pointer active:scale-95"
|
|
||||||
>
|
|
||||||
{!loaded ? (
|
|
||||||
<Loader2 className="h-6 w-6 animate-spin" />
|
|
||||||
) : playing ? (
|
|
||||||
<Pause className="h-6 w-6" />
|
|
||||||
) : (
|
|
||||||
<Play className="h-6 w-6 ml-0.5" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
<div className="w-[104px]" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Video renderer ──
|
|
||||||
export const VideoRenderer = ({ src, fileName }: { src: string; fileName: string }) => {
|
|
||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
|
||||||
const hideTimer = useRef<ReturnType<typeof setTimeout>>(null);
|
|
||||||
const [playing, setPlaying] = useState(false);
|
|
||||||
const [currentTime, setCurrent] = useState(0);
|
|
||||||
const [duration, setDuration] = useState(0);
|
|
||||||
const [volume, setVolume] = useState(1);
|
|
||||||
const [muted, setMuted] = useState(false);
|
|
||||||
const [loaded, setLoaded] = useState(false);
|
|
||||||
const [error, setError] = useState(false);
|
|
||||||
const [showControls, setShowControls] = useState(true);
|
|
||||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
|
||||||
const { barRef, onSeekDown } = useSeekBar(videoRef, duration);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const v = videoRef.current;
|
|
||||||
if (!v) return;
|
|
||||||
const onLoaded = () => {
|
|
||||||
setDuration(v.duration);
|
|
||||||
setLoaded(true);
|
|
||||||
};
|
|
||||||
const onTime = () => setCurrent(v.currentTime);
|
|
||||||
const onPlay = () => setPlaying(true);
|
|
||||||
const onPause = () => setPlaying(false);
|
|
||||||
const onEnded = () => setPlaying(false);
|
|
||||||
const onError = () => setError(true);
|
|
||||||
v.addEventListener('loadedmetadata', onLoaded);
|
|
||||||
v.addEventListener('timeupdate', onTime);
|
|
||||||
v.addEventListener('play', onPlay);
|
|
||||||
v.addEventListener('pause', onPause);
|
|
||||||
v.addEventListener('ended', onEnded);
|
|
||||||
v.addEventListener('error', onError);
|
|
||||||
return () => {
|
|
||||||
v.removeEventListener('loadedmetadata', onLoaded);
|
|
||||||
v.removeEventListener('timeupdate', onTime);
|
|
||||||
v.removeEventListener('play', onPlay);
|
|
||||||
v.removeEventListener('pause', onPause);
|
|
||||||
v.removeEventListener('ended', onEnded);
|
|
||||||
v.removeEventListener('error', onError);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const onChange = () => setIsFullscreen(!!document.fullscreenElement);
|
|
||||||
document.addEventListener('fullscreenchange', onChange);
|
|
||||||
return () => document.removeEventListener('fullscreenchange', onChange);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const resetHideTimer = useCallback(() => {
|
|
||||||
setShowControls(true);
|
|
||||||
if (hideTimer.current) clearTimeout(hideTimer.current);
|
|
||||||
hideTimer.current = setTimeout(() => {
|
|
||||||
if (videoRef.current && !videoRef.current.paused) setShowControls(false);
|
|
||||||
}, 2500);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const togglePlay = useCallback(() => {
|
|
||||||
const v = videoRef.current;
|
|
||||||
if (!v) return;
|
|
||||||
if (v.paused) v.play();
|
|
||||||
else v.pause();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const changeVolume = useCallback((ev: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const val = parseFloat(ev.target.value);
|
|
||||||
setVolume(val);
|
|
||||||
setMuted(val === 0);
|
|
||||||
if (videoRef.current) videoRef.current.volume = val;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const toggleMute = useCallback(() => {
|
|
||||||
const v = videoRef.current;
|
|
||||||
if (!v) return;
|
|
||||||
if (muted) {
|
|
||||||
v.volume = volume || 1;
|
|
||||||
setMuted(false);
|
|
||||||
} else {
|
|
||||||
v.volume = 0;
|
|
||||||
setMuted(true);
|
|
||||||
}
|
|
||||||
}, [muted, volume]);
|
|
||||||
|
|
||||||
const toggleFullscreen = useCallback(() => {
|
|
||||||
const c = containerRef.current;
|
|
||||||
if (!c) return;
|
|
||||||
if (document.fullscreenElement) document.exitFullscreen();
|
|
||||||
else c.requestFullscreen();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const pct = duration > 0 ? (currentTime / duration) * 100 : 0;
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center h-full">
|
|
||||||
<span className="text-sm text-red-500">Failed to load video</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={containerRef}
|
|
||||||
className="relative w-full h-full bg-black flex items-center justify-center group"
|
|
||||||
onMouseMove={resetHideTimer}
|
|
||||||
onMouseLeave={() => {
|
|
||||||
if (playing) setShowControls(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<video ref={videoRef} src={src} preload="metadata" className="max-w-full max-h-full" onClick={togglePlay} />
|
|
||||||
|
|
||||||
{loaded && !playing && (
|
|
||||||
<button onClick={togglePlay} className="absolute inset-0 flex items-center justify-center cursor-pointer">
|
|
||||||
<div className="w-16 h-16 rounded-full bg-black/50 backdrop-blur-sm flex items-center justify-center">
|
|
||||||
<Play className="h-8 w-8 text-white ml-1" />
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!loaded && (
|
|
||||||
<div className="absolute inset-0 flex items-center justify-center">
|
|
||||||
<Loader2 className="h-8 w-8 text-white animate-spin" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div
|
|
||||||
className={`absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 via-black/40 to-transparent pt-12 pb-3 px-4 transition-opacity duration-300 ${
|
|
||||||
showControls || !playing ? 'opacity-100' : 'opacity-0 pointer-events-none'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<SeekBar
|
|
||||||
barRef={barRef}
|
|
||||||
onSeekDown={onSeekDown}
|
|
||||||
pct={pct}
|
|
||||||
trackClass="bg-white/20"
|
|
||||||
fillClass="bg-duck-teal"
|
|
||||||
thumbClass="bg-duck-teal border-white"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-3 mt-1">
|
|
||||||
<button onClick={togglePlay} className="p-1 text-white hover:text-duck-teal transition-colors cursor-pointer">
|
|
||||||
{playing ? <Pause className="h-5 w-5" /> : <Play className="h-5 w-5" />}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<button
|
|
||||||
onClick={toggleMute}
|
|
||||||
className="p-1 text-white/70 hover:text-white transition-colors cursor-pointer"
|
|
||||||
>
|
|
||||||
{muted ? <VolumeX className="h-4 w-4" /> : <Volume2 className="h-4 w-4" />}
|
|
||||||
</button>
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min={0}
|
|
||||||
max={1}
|
|
||||||
step={0.01}
|
|
||||||
value={muted ? 0 : volume}
|
|
||||||
onChange={changeVolume}
|
|
||||||
className="w-16 h-1 accent-duck-teal cursor-pointer"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<span className="text-xs font-mono text-white/60 select-none">
|
|
||||||
{formatTime(currentTime)} / {formatTime(duration)}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<div className="flex-1" />
|
|
||||||
|
|
||||||
<span className="text-[9px] font-mono text-white/30 uppercase tracking-wider">{getExt(fileName)}</span>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={toggleFullscreen}
|
|
||||||
className="p-1 text-white/70 hover:text-white transition-colors cursor-pointer"
|
|
||||||
>
|
|
||||||
{isFullscreen ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Image renderer ──
|
|
||||||
const ZOOM_STEPS = [0.25, 0.5, 0.75, 1, 1.5, 2, 3, 5];
|
|
||||||
|
|
||||||
export const ImageRenderer = ({ src, fileName }: { src: string; fileName: string }) => {
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
|
||||||
const minZoom = ZOOM_STEPS[0] ?? 1;
|
|
||||||
const maxZoom = ZOOM_STEPS[ZOOM_STEPS.length - 1] ?? 1;
|
|
||||||
const [zoom, setZoom] = useState(1);
|
|
||||||
const [rotation, setRotation] = useState(0);
|
|
||||||
const [loaded, setLoaded] = useState(false);
|
|
||||||
const [error, setError] = useState(false);
|
|
||||||
const [dragging, setDragging] = useState(false);
|
|
||||||
const [offset, setOffset] = useState({ x: 0, y: 0 });
|
|
||||||
const dragStart = useRef({ x: 0, y: 0, ox: 0, oy: 0 });
|
|
||||||
|
|
||||||
const zoomIn = useCallback(() => {
|
|
||||||
setZoom((z) => {
|
|
||||||
const next = ZOOM_STEPS.find((s) => s > z);
|
|
||||||
return next ?? z;
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const zoomOut = useCallback(() => {
|
|
||||||
setZoom((z) => {
|
|
||||||
const prev = [...ZOOM_STEPS].reverse().find((s) => s < z);
|
|
||||||
return prev ?? z;
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const resetView = useCallback(() => {
|
|
||||||
setZoom(1);
|
|
||||||
setRotation(0);
|
|
||||||
setOffset({ x: 0, y: 0 });
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const rotate = useCallback(() => {
|
|
||||||
setRotation((r) => (r + 90) % 360);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const onWheel = useCallback(
|
|
||||||
(ev: React.WheelEvent) => {
|
|
||||||
ev.preventDefault();
|
|
||||||
if (ev.deltaY < 0) zoomIn();
|
|
||||||
else zoomOut();
|
|
||||||
},
|
|
||||||
[zoomIn, zoomOut],
|
|
||||||
);
|
|
||||||
|
|
||||||
const onMouseDown = useCallback(
|
|
||||||
(ev: React.MouseEvent) => {
|
|
||||||
if (zoom <= 1) return;
|
|
||||||
ev.preventDefault();
|
|
||||||
setDragging(true);
|
|
||||||
dragStart.current = { x: ev.clientX, y: ev.clientY, ox: offset.x, oy: offset.y };
|
|
||||||
|
|
||||||
const onMove = (e: MouseEvent) => {
|
|
||||||
setOffset({
|
|
||||||
x: dragStart.current.ox + (e.clientX - dragStart.current.x),
|
|
||||||
y: dragStart.current.oy + (e.clientY - dragStart.current.y),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
const onUp = () => {
|
|
||||||
setDragging(false);
|
|
||||||
window.removeEventListener('mousemove', onMove);
|
|
||||||
window.removeEventListener('mouseup', onUp);
|
|
||||||
};
|
|
||||||
window.addEventListener('mousemove', onMove);
|
|
||||||
window.addEventListener('mouseup', onUp);
|
|
||||||
},
|
|
||||||
[zoom, offset],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (zoom <= 1) setOffset({ x: 0, y: 0 });
|
|
||||||
}, [zoom]);
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center h-full">
|
|
||||||
<span className="text-sm text-red-500">Failed to load image</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative w-full h-full flex flex-col">
|
|
||||||
<div
|
|
||||||
ref={containerRef}
|
|
||||||
className={`flex-1 min-h-0 flex items-center justify-center overflow-hidden bg-[repeating-conic-gradient(hsl(var(--muted))_0%_25%,transparent_0%_50%)] bg-[length:16px_16px] ${
|
|
||||||
zoom > 1 ? (dragging ? 'cursor-grabbing' : 'cursor-grab') : 'cursor-zoom-in'
|
|
||||||
}`}
|
|
||||||
onWheel={onWheel}
|
|
||||||
onMouseDown={zoom > 1 ? onMouseDown : undefined}
|
|
||||||
onClick={zoom <= 1 ? zoomIn : undefined}
|
|
||||||
>
|
|
||||||
{!loaded && (
|
|
||||||
<div className="absolute inset-0 flex items-center justify-center">
|
|
||||||
<Loader2 className="h-6 w-6 text-duck-teal animate-spin" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<img
|
|
||||||
src={src}
|
|
||||||
alt={fileName}
|
|
||||||
onLoad={() => setLoaded(true)}
|
|
||||||
onError={() => setError(true)}
|
|
||||||
className="transition-transform duration-150 select-none"
|
|
||||||
draggable={false}
|
|
||||||
style={{
|
|
||||||
transform: `translate(${offset.x}px, ${offset.y}px) scale(${zoom}) rotate(${rotation}deg)`,
|
|
||||||
maxWidth: zoom <= 1 ? '100%' : 'none',
|
|
||||||
maxHeight: zoom <= 1 ? '100%' : 'none',
|
|
||||||
opacity: loaded ? 1 : 0,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="shrink-0 flex items-center justify-center gap-1 py-2 border-t border-duck-dark/10 bg-background/80">
|
|
||||||
<button
|
|
||||||
onClick={zoomOut}
|
|
||||||
disabled={zoom <= minZoom}
|
|
||||||
className="p-1.5 rounded-md text-duck-dark/50 hover:text-duck-dark hover:bg-duck-dark/5 disabled:opacity-30 transition-colors cursor-pointer"
|
|
||||||
>
|
|
||||||
<ZoomOut className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={resetView}
|
|
||||||
className="px-2 py-1 rounded-md text-xs font-mono text-duck-dark/50 hover:text-duck-dark hover:bg-duck-dark/5 transition-colors cursor-pointer min-w-[4rem] text-center"
|
|
||||||
>
|
|
||||||
{Math.round(zoom * 100)}%
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={zoomIn}
|
|
||||||
disabled={zoom >= maxZoom}
|
|
||||||
className="p-1.5 rounded-md text-duck-dark/50 hover:text-duck-dark hover:bg-duck-dark/5 disabled:opacity-30 transition-colors cursor-pointer"
|
|
||||||
>
|
|
||||||
<ZoomIn className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
<div className="w-px h-4 bg-duck-dark/10 mx-1" />
|
|
||||||
<button
|
|
||||||
onClick={rotate}
|
|
||||||
className="p-1.5 rounded-md text-duck-dark/50 hover:text-duck-dark hover:bg-duck-dark/5 transition-colors cursor-pointer"
|
|
||||||
>
|
|
||||||
<RotateCw className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── PDF renderer ──
|
|
||||||
export const PdfRenderer = ({ src }: { src: string }) => (
|
|
||||||
<iframe src={src} className="w-full h-full border-0" title="PDF viewer" />
|
|
||||||
);
|
|
||||||
|
|
||||||
// ── Backward-compat FileViewerView wrapper ──
|
|
||||||
type FileViewerViewProps = {
|
|
||||||
filePath: string;
|
|
||||||
fileName: string;
|
|
||||||
root?: string;
|
|
||||||
content?: string;
|
|
||||||
onClose?: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const FileViewerView = ({ filePath, fileName, root, content, onClose }: FileViewerViewProps) => {
|
|
||||||
return (
|
|
||||||
<FileViewerProvider filePath={filePath} fileName={fileName} root={root} content={content}>
|
|
||||||
<div className="h-full flex flex-col">
|
|
||||||
<div className="shrink-0 flex items-center gap-2 px-4 py-1.5 border-b border-duck-dark/10 dark:border-foreground/10 bg-background/60 text-duck-dark/70 dark:text-foreground/70">
|
|
||||||
<FileViewerHeader />
|
|
||||||
{onClose && (
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="p-1 rounded hover:bg-duck-dark/10 dark:hover:bg-foreground/10 transition-colors cursor-pointer"
|
|
||||||
>
|
|
||||||
<X className="h-3.5 w-3.5" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-h-0">
|
|
||||||
<FileViewerBody />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</FileViewerProvider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
export { FileViewerView } from './FileViewerView';
|
|
||||||
export { FileViewerProvider, FileViewerHeader, FileViewerBody } from './FileViewerContext';
|
|
||||||
export { getFileType, getLang, getExt, getArchiveBaseName, ARCHIVE_EXTS, type FileType } from './file-types';
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
{
|
|
||||||
"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",
|
|
||||||
"./FileViewer": "./FileViewer/index.ts"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState, useCallback, useEffect } from 'react';
|
import { useState, useCallback, useEffect } from 'react';
|
||||||
import { flushSync } from 'react-dom';
|
import { flushSync } from 'react-dom';
|
||||||
import type { LayoutNode, AppRegistry, WorkspaceState } from './types';
|
import { ResizablePanel, ResizablePanelGroup, ResizableHandle } from '../ui/resizable';
|
||||||
|
import type { LayoutNode, AppRegistry, WorkspaceState, EphemeralPanels } from './types';
|
||||||
import type { DropPosition } from './layout-utils';
|
import type { DropPosition } from './layout-utils';
|
||||||
import { splitPanel, removePanel, setApp, updateSizes, swapPanels, movePanel, countPanels } from './layout-utils';
|
import { splitPanel, removePanel, setApp, updateSizes, swapPanels, movePanel, countPanels } from './layout-utils';
|
||||||
import { WorkspaceProvider } from './WorkspaceContext';
|
import { WorkspaceProvider } from './WorkspaceContext';
|
||||||
@@ -10,9 +11,12 @@ type WorkspaceViewProps = {
|
|||||||
workspace: WorkspaceState;
|
workspace: WorkspaceState;
|
||||||
registry: AppRegistry;
|
registry: AppRegistry;
|
||||||
cwd?: string;
|
cwd?: string;
|
||||||
|
ephemeral?: EphemeralPanels | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const WorkspaceView = ({ workspace, registry, cwd = '~' }: WorkspaceViewProps) => {
|
const noop = () => {};
|
||||||
|
|
||||||
|
export const WorkspaceView = ({ workspace, registry, cwd = '~', ephemeral }: WorkspaceViewProps) => {
|
||||||
if (!workspace.isLoaded) return null;
|
if (!workspace.isLoaded) return null;
|
||||||
|
|
||||||
const { value: layout, setValue: onLayoutChange } = workspace;
|
const { value: layout, setValue: onLayoutChange } = workspace;
|
||||||
@@ -101,6 +105,40 @@ export const WorkspaceView = ({ workspace, registry, cwd = '~' }: WorkspaceViewP
|
|||||||
return () => window.removeEventListener('keydown', onKeyDown);
|
return () => window.removeEventListener('keydown', onKeyDown);
|
||||||
}, [swapSourceId, dragSourceId, maximizedPanelId, setMaximizedAnimated]);
|
}, [swapSourceId, dragSourceId, maximizedPanelId, setMaximizedAnimated]);
|
||||||
|
|
||||||
|
const baseRenderer = (
|
||||||
|
<WorkspaceRenderer
|
||||||
|
layout={layout}
|
||||||
|
registry={registry}
|
||||||
|
interactive
|
||||||
|
onSetApp={handleSetApp}
|
||||||
|
onSplit={handleSplit}
|
||||||
|
onRemove={handleRemove}
|
||||||
|
onResized={handleResized}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const content = ephemeral ? (
|
||||||
|
<ResizablePanelGroup direction="horizontal" className="h-full w-full">
|
||||||
|
<ResizablePanel defaultSize={ephemeral.defaultBaseSize ?? 40} minSize={15}>
|
||||||
|
{baseRenderer}
|
||||||
|
</ResizablePanel>
|
||||||
|
<ResizableHandle className="bg-transparent after:bg-transparent" />
|
||||||
|
<ResizablePanel defaultSize={100 - (ephemeral.defaultBaseSize ?? 40)} minSize={15}>
|
||||||
|
<WorkspaceRenderer
|
||||||
|
layout={ephemeral.layout}
|
||||||
|
registry={registry}
|
||||||
|
components={ephemeral.components}
|
||||||
|
onSetApp={noop}
|
||||||
|
onSplit={noop}
|
||||||
|
onRemove={noop}
|
||||||
|
onResized={noop}
|
||||||
|
/>
|
||||||
|
</ResizablePanel>
|
||||||
|
</ResizablePanelGroup>
|
||||||
|
) : (
|
||||||
|
baseRenderer
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<WorkspaceProvider
|
<WorkspaceProvider
|
||||||
value={{
|
value={{
|
||||||
@@ -117,16 +155,7 @@ export const WorkspaceView = ({ workspace, registry, cwd = '~' }: WorkspaceViewP
|
|||||||
transitioningPanelId,
|
transitioningPanelId,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<WorkspaceRenderer
|
{content}
|
||||||
layout={layout}
|
|
||||||
registry={registry}
|
|
||||||
interactive
|
|
||||||
onSetApp={handleSetApp}
|
|
||||||
onSplit={handleSplit}
|
|
||||||
onRemove={handleRemove}
|
|
||||||
onResized={handleResized}
|
|
||||||
/>
|
|
||||||
{/* <DragOverlay /> */}
|
|
||||||
</WorkspaceProvider>
|
</WorkspaceProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export type { LayoutNode, LayoutGroup, LayoutPanel, WorkspaceDefinition, WorkspaceState, ProjectType, ProjectDefinition, AppRegistry, AppRegistryEntry, PanelComponents, PanelComponentEntry } from './types';
|
export type { LayoutNode, LayoutGroup, LayoutPanel, WorkspaceDefinition, WorkspaceState, ProjectType, ProjectDefinition, AppRegistry, AppRegistryEntry, PanelComponents, PanelComponentEntry, EphemeralPanels } from './types';
|
||||||
export type { DropPosition } from './layout-utils';
|
export type { DropPosition } from './layout-utils';
|
||||||
export { createDefaultLayout, splitPanel, removePanel, setApp, updateSizes, swapPanels, movePanel, pruneEmptyPanels, countPanels, hasAnyApp } from './layout-utils';
|
export { createDefaultLayout, splitPanel, removePanel, setApp, updateSizes, swapPanels, movePanel, pruneEmptyPanels, countPanels, hasAnyApp } from './layout-utils';
|
||||||
export { WorkspaceProvider, useWorkspace } from './WorkspaceContext';
|
export { WorkspaceProvider, useWorkspace } from './WorkspaceContext';
|
||||||
|
|||||||
@@ -67,3 +67,9 @@ export type PanelComponentEntry = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type PanelComponents = Record<string, ComponentType | PanelComponentEntry>;
|
export type PanelComponents = Record<string, ComponentType | PanelComponentEntry>;
|
||||||
|
|
||||||
|
export type EphemeralPanels = {
|
||||||
|
layout: LayoutNode;
|
||||||
|
components: PanelComponents;
|
||||||
|
defaultBaseSize?: number;
|
||||||
|
};
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
# App Conventions
|
||||||
|
|
||||||
|
Patterns and conventions for building apps in the `officerdev` workspace. These complement the root `CLAUDE.md` and `CONVENTIONS.md`.
|
||||||
|
|
||||||
|
## Manager Pattern
|
||||||
|
|
||||||
|
Each app has a central hook that owns all state, effects, and handlers. Components receive this single object as a prop and destructure what they need internally.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Hook
|
||||||
|
export const useFileBrowserApp = (basePath: string) => {
|
||||||
|
const [entries, setEntries] = useState<DirEntry[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
// ... all state, effects, handlers
|
||||||
|
|
||||||
|
return { entries, loading, refresh, handleOpen, /* ... */ };
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UseFileBrowserAppType = ReturnType<typeof useFileBrowserApp>;
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Component receives the manager as a single prop
|
||||||
|
type ToolbarProps = {
|
||||||
|
fileBrowserManager: UseFileBrowserAppType;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Toolbar = ({ fileBrowserManager }: ToolbarProps) => {
|
||||||
|
const { searchQuery, setSearchQuery, viewMode, setViewMode } = fileBrowserManager;
|
||||||
|
// ...
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Root component creates the manager and passes it down
|
||||||
|
export const FileBrowserApp = ({ basePath = '/' }: FileBrowserAppProps) => {
|
||||||
|
const fileBrowserManager = useFileBrowserApp(basePath);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full overflow-hidden">
|
||||||
|
<Toolbar fileBrowserManager={fileBrowserManager} />
|
||||||
|
<FileViewContainer fileBrowserManager={fileBrowserManager} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**Exception**: Presentational leaf components that receive computed/derived values (e.g. `FileItem`, `EntryRow`) keep individual props since they don't interact with the manager directly.
|
||||||
|
|
||||||
|
## Directory Structure
|
||||||
|
|
||||||
|
Apps live in `src/apps/<AppName>/` with this layout:
|
||||||
|
|
||||||
|
```
|
||||||
|
FileBrowser/
|
||||||
|
├── index.ts # Public barrel (exports for external consumers)
|
||||||
|
├── useFiles.ts # Shared API hooks
|
||||||
|
├── useTasks.ts
|
||||||
|
├── FileBrowserApp/
|
||||||
|
│ ├── index.ts # Re-exports FileBrowserApp
|
||||||
|
│ ├── FileBrowserApp.tsx # Slim composition root
|
||||||
|
│ ├── useFileBrowserApp.ts # Manager hook
|
||||||
|
│ └── components/
|
||||||
|
│ ├── Breadcrumb.tsx
|
||||||
|
│ ├── FileGrid.tsx
|
||||||
|
│ ├── FileViewContainer.tsx
|
||||||
|
│ └── Toolbar/
|
||||||
|
│ ├── index.ts
|
||||||
|
│ ├── Toolbar.tsx
|
||||||
|
│ ├── ActionButtons.tsx
|
||||||
|
│ ├── SelectionActions.tsx
|
||||||
|
│ └── DefaultActions.tsx
|
||||||
|
└── FileBrowserWidget/
|
||||||
|
├── index.ts
|
||||||
|
├── FileBrowserWidget.tsx
|
||||||
|
├── useFileBrowserWidget.ts
|
||||||
|
└── components/
|
||||||
|
├── Header.tsx
|
||||||
|
├── BrowseTab.tsx
|
||||||
|
└── EntryRow.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
Key rules:
|
||||||
|
- Feature directories use `Feature/Feature.tsx` + `Feature/index.ts` barrel
|
||||||
|
- Components that warrant further breakdown get their own directory (e.g. `Toolbar/`)
|
||||||
|
- Shared components can be imported across sibling apps (e.g. Widget imports `Breadcrumb` from App)
|
||||||
|
- The root `index.ts` is the public API — only export what external consumers need
|
||||||
|
|
||||||
|
## File Ordering
|
||||||
|
|
||||||
|
Within a file, order sections as:
|
||||||
|
|
||||||
|
1. Imports (types → external → workspace → relative)
|
||||||
|
2. Constants
|
||||||
|
3. Main export (component or hook)
|
||||||
|
4. `ReturnType` export (for hooks)
|
||||||
|
5. Types (props types for sub-components)
|
||||||
|
6. Helper components / functions
|
||||||
|
|
||||||
|
## No useCallback or useMemo (React 19)
|
||||||
|
|
||||||
|
React 19's compiler handles memoization. Never use `useCallback` or `useMemo`.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Plain function — not wrapped in useCallback
|
||||||
|
const refresh = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
const data = await files.listDir(currentPath);
|
||||||
|
setEntries(data.entries);
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Inline ref callback — not wrapped in useCallback
|
||||||
|
<input ref={(node) => {
|
||||||
|
inputRef.current = node;
|
||||||
|
if (node) node.setAttribute('webkitdirectory', '');
|
||||||
|
}} />
|
||||||
|
|
||||||
|
// IIFE for derived values — not wrapped in useMemo
|
||||||
|
const sorted = (() => {
|
||||||
|
const compare = (a: DirEntry, b: DirEntry): number => { /* ... */ };
|
||||||
|
return [...entries].sort(compare);
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Simple derivations — just inline
|
||||||
|
const svg = getIcon(name).svg;
|
||||||
|
const isSearching = searchQuery.trim().length > 0;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Component Extraction
|
||||||
|
|
||||||
|
Break monolithic components into focused sub-components in a `components/` directory. Each handles its own null guards internally.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Component handles its own visibility logic
|
||||||
|
export const UploadProgress = ({ fileBrowserManager }: UploadProgressProps) => {
|
||||||
|
const { uploadProgress } = fileBrowserManager;
|
||||||
|
if (uploadProgress === null) return null;
|
||||||
|
return <div>/* progress bar */</div>;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Parent stays clean — no conditional rendering
|
||||||
|
<UploadProgress fileBrowserManager={fileBrowserManager} />
|
||||||
|
```
|
||||||
|
|
||||||
|
## Keep It Simple
|
||||||
|
|
||||||
|
- Use `prompt()` for simple user input instead of inline forms with state management
|
||||||
|
- Group hidden `<input type="file">` elements inside their trigger buttons
|
||||||
|
- Don't over-decompose — if a component is under ~30 lines of JSX, it probably doesn't need extraction
|
||||||
|
- Prefer early returns over nested ternaries for loading/empty states
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
# Hook Conventions
|
||||||
|
|
||||||
|
Patterns and conventions for building hooks in the `officerdev` workspace. These complement the root `CLAUDE.md` and `APP_CONVENTIONS.md`.
|
||||||
|
|
||||||
|
## File Ordering
|
||||||
|
|
||||||
|
Within a hook file, order sections as:
|
||||||
|
|
||||||
|
1. Imports (types → external → workspace → relative)
|
||||||
|
2. Constants
|
||||||
|
3. Main export (the hook)
|
||||||
|
4. `export type UseXType = ReturnType<typeof useX>`
|
||||||
|
5. Types (params types, internal types)
|
||||||
|
6. Helper functions
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import type { ChatMessage } from '../apps/Chat/types';
|
||||||
|
import { useState, useRef } from 'react';
|
||||||
|
import { useClient } from 'hooks/useClient';
|
||||||
|
|
||||||
|
const DEBOUNCE_MS = 1000;
|
||||||
|
|
||||||
|
export function usePiChat(sessionId?: string, model?: string | null) {
|
||||||
|
// ...
|
||||||
|
return { messages, sendPrompt, stopGeneration };
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UsePiChatType = ReturnType<typeof usePiChat>;
|
||||||
|
|
||||||
|
// ── Types ──
|
||||||
|
|
||||||
|
type InternalMessage = {
|
||||||
|
id: string;
|
||||||
|
timestamp: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Helpers ──
|
||||||
|
|
||||||
|
const formatMessage = (msg: InternalMessage) => { /* ... */ };
|
||||||
|
```
|
||||||
|
|
||||||
|
## Simple Hooks — Single File
|
||||||
|
|
||||||
|
Hooks that are self-contained (one function, a few types, maybe some helpers) stay as a single file in `src/hooks/`.
|
||||||
|
|
||||||
|
```
|
||||||
|
hooks/
|
||||||
|
├── useFilesAPI.ts
|
||||||
|
├── usePiChat.ts
|
||||||
|
└── index.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
The file is named after the hook: `useFilesAPI.ts` exports `useFilesAPI`.
|
||||||
|
|
||||||
|
## Complex Hooks — Directory
|
||||||
|
|
||||||
|
When a hook accumulates supporting code (constants, components, config), promote it to a directory:
|
||||||
|
|
||||||
|
```
|
||||||
|
hooks/
|
||||||
|
├── useFileViewerPanels/
|
||||||
|
│ ├── index.ts # Barrel re-export
|
||||||
|
│ ├── useFileViewerPanels.tsx # The hook
|
||||||
|
│ ├── layouts.ts # Static data / constants
|
||||||
|
│ └── Providers.tsx # Helper components
|
||||||
|
└── index.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- `index.ts` is a barrel — only re-exports the hook and its `ReturnType`
|
||||||
|
- The hook file is named `useX.ts(x)` matching the directory name
|
||||||
|
- Supporting files are named by what they contain: `layouts.ts`, `Providers.tsx`
|
||||||
|
- Component files are **PascalCased** (e.g. `Providers.tsx`), everything else is **kebab-case** or **camelCase**
|
||||||
|
- The parent `hooks/index.ts` barrel doesn't change when promoting — `export * from './useFileViewerPanels'` resolves to the directory's `index.ts`
|
||||||
|
|
||||||
|
## When to Promote
|
||||||
|
|
||||||
|
Promote a single-file hook to a directory when:
|
||||||
|
- It has **3+ non-trivial constants** that deserve their own file (layouts, configs, mappings)
|
||||||
|
- It contains **React components** used as helpers (providers, wrappers)
|
||||||
|
- The file exceeds **~200 lines** and has clear separable concerns
|
||||||
|
|
||||||
|
Don't promote for:
|
||||||
|
- A couple of small types or helper functions — keep them in the same file
|
||||||
|
- Hooks that are just API wrappers (like `useFilesAPI`)
|
||||||
|
|
||||||
|
## Return Type Export
|
||||||
|
|
||||||
|
Always export a `ReturnType` alias immediately after the hook. This lets consumers type props without importing the hook itself:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export function useFilesAPI(root: string = 'home') {
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UseFilesAPIType = ReturnType<typeof useFilesAPI>;
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Consumer uses the type without calling the hook
|
||||||
|
import type { UseFilesAPIType } from 'officerdev';
|
||||||
|
|
||||||
|
type ToolbarProps = {
|
||||||
|
files: UseFilesAPIType;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Barrel Exports
|
||||||
|
|
||||||
|
The root `hooks/index.ts` re-exports everything. Each entry is a single `export *` line:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export * from './appRegistry';
|
||||||
|
export * from './useFilesAPI';
|
||||||
|
export * from './useFileViewerPanels';
|
||||||
|
export * from './usePiChat';
|
||||||
|
```
|
||||||
|
|
||||||
|
When adding a new hook, add one line here. Order alphabetically.
|
||||||
@@ -6,5 +6,9 @@
|
|||||||
"exports": {
|
"exports": {
|
||||||
".": "./src/index.ts",
|
".": "./src/index.ts",
|
||||||
"./*": "./src/*.ts"
|
"./*": "./src/*.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"hooks": "workspace:*",
|
||||||
|
"widgets": "workspace:*"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { useState, useRef, useEffect, type KeyboardEvent } from 'react';
|
||||||
|
import { Send } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import type { ModelOption } from '../types';
|
||||||
|
import { useAttachments } from '../useAttachments';
|
||||||
|
import { ModelSelector } from '../components/ModelSelector';
|
||||||
|
import { AttachmentList } from '../components/AttachmentList';
|
||||||
|
import { AttachButton } from '../components/AttachButton';
|
||||||
|
import { WebpageDialog } from '../components/WebpageDialog';
|
||||||
|
|
||||||
|
type ChatLauncherProps = {
|
||||||
|
availableModels: ModelOption[];
|
||||||
|
selectedModel: string | null;
|
||||||
|
onModelChange: (modelId: string) => void;
|
||||||
|
onSubmit: (data: {
|
||||||
|
prompt: string;
|
||||||
|
model: string | null;
|
||||||
|
attachmentIds?: string[];
|
||||||
|
images?: { filename: string; dataUrl: string }[];
|
||||||
|
}) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ChatLauncher({
|
||||||
|
availableModels,
|
||||||
|
selectedModel,
|
||||||
|
onModelChange,
|
||||||
|
onSubmit,
|
||||||
|
placeholder = 'What do you want to work on now?',
|
||||||
|
}: ChatLauncherProps) {
|
||||||
|
const { attachments, attachWebpage, attachImage, removeAttachment, clearAttachments, processAttachments } =
|
||||||
|
useAttachments();
|
||||||
|
const [input, setInput] = useState('');
|
||||||
|
const [urlDialogOpen, setUrlDialogOpen] = useState(false);
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
const text = input.trim();
|
||||||
|
if (!text) return;
|
||||||
|
|
||||||
|
const { prefix, ids, images } = processAttachments();
|
||||||
|
const prompt = prefix ? `${prefix}${text}` : text;
|
||||||
|
|
||||||
|
onSubmit({
|
||||||
|
prompt,
|
||||||
|
model: selectedModel,
|
||||||
|
attachmentIds: ids.length > 0 ? ids : undefined,
|
||||||
|
images: images.length > 0 ? images : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
setInput('');
|
||||||
|
clearAttachments();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (ev: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
|
if (ev.key === 'Enter' && !ev.shiftKey) {
|
||||||
|
ev.preventDefault();
|
||||||
|
handleSubmit();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Auto-resize textarea
|
||||||
|
useEffect(() => {
|
||||||
|
const textarea = textareaRef.current;
|
||||||
|
if (!textarea) return;
|
||||||
|
textarea.style.height = 'auto';
|
||||||
|
textarea.style.height = Math.min(textarea.scrollHeight, 160) + 'px';
|
||||||
|
}, [input]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4 pb-2 pt-1">
|
||||||
|
<AttachmentList attachments={attachments} onRemove={removeAttachment} />
|
||||||
|
|
||||||
|
<div className="flex items-end gap-2">
|
||||||
|
<AttachButton size="md" onAttachImage={attachImage} onAttachWebpage={() => setUrlDialogOpen(true)} />
|
||||||
|
<textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
value={input}
|
||||||
|
onChange={(ev) => setInput(ev.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onPaste={(ev) => {
|
||||||
|
const items = ev.clipboardData?.items;
|
||||||
|
if (!items) return;
|
||||||
|
for (const item of Array.from(items)) {
|
||||||
|
if (item.type.startsWith('image/')) {
|
||||||
|
ev.preventDefault();
|
||||||
|
const file = item.getAsFile();
|
||||||
|
if (file) attachImage(file);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder={placeholder}
|
||||||
|
rows={1}
|
||||||
|
className="flex-1 resize-none bg-transparent px-2 py-2 text-duck-dark placeholder:text-duck-dark/30 focus:outline-none text-lg"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={!input.trim()}
|
||||||
|
size="icon"
|
||||||
|
className="shrink-0 h-10 w-10 bg-duck-teal hover:bg-duck-teal/90 cursor-pointer disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<Send className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ModelSelector
|
||||||
|
messages={[]}
|
||||||
|
availableModels={availableModels}
|
||||||
|
selectedModel={selectedModel}
|
||||||
|
onModelChange={onModelChange}
|
||||||
|
model={selectedModel}
|
||||||
|
isConnected={true}
|
||||||
|
isGenerating={false}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<WebpageDialog open={urlDialogOpen} onOpenChange={setUrlDialogOpen} onSubmit={attachWebpage} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { ChatLauncher } from './ChatLauncher';
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import type { UsePiChatType } from '../../../hooks/usePiChat';
|
||||||
|
import { useEmbeddableChat } from './useEmbeddableChat';
|
||||||
|
import { MessageList } from '../components/MessageList';
|
||||||
|
import { InputArea } from '../components/InputArea';
|
||||||
|
|
||||||
|
type EmbeddableChatProps = {
|
||||||
|
sessionId?: string;
|
||||||
|
initialModel?: string | null;
|
||||||
|
initialMessage?: {
|
||||||
|
text: string;
|
||||||
|
attachmentIds?: string[];
|
||||||
|
images?: { filename: string; dataUrl: string }[];
|
||||||
|
cwd?: { root?: string; path: string };
|
||||||
|
};
|
||||||
|
defaultInput?: string;
|
||||||
|
promptPrefix?: string;
|
||||||
|
className?: string;
|
||||||
|
cwd?: { root?: string; path: string };
|
||||||
|
autoSend?: boolean;
|
||||||
|
chat?: UsePiChatType;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EmbeddableChat = ({ className, ...params }: EmbeddableChatProps) => {
|
||||||
|
const manager = useEmbeddableChat(params);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`flex flex-col ${className ?? ''}`}>
|
||||||
|
<MessageList manager={manager} />
|
||||||
|
<InputArea manager={manager} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { EmbeddableChat } from './EmbeddableChat';
|
||||||
|
export { useEmbeddableChat, type UseEmbeddableChatType } from './useEmbeddableChat';
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
import type { KeyboardEvent } from 'react';
|
||||||
|
import { useState, useRef, useEffect } from 'react';
|
||||||
|
import { useVisiblePiModels } from '@/state/useModels';
|
||||||
|
import { usePiChat, type UsePiChatType } from '../../../hooks/usePiChat';
|
||||||
|
import { useAttachments } from '../useAttachments';
|
||||||
|
import { useSlashCommands } from '../useSlashCommands';
|
||||||
|
|
||||||
|
type UseEmbeddableChatParams = {
|
||||||
|
sessionId?: string;
|
||||||
|
initialModel?: string | null;
|
||||||
|
initialMessage?: {
|
||||||
|
text: string;
|
||||||
|
attachmentIds?: string[];
|
||||||
|
images?: { filename: string; dataUrl: string }[];
|
||||||
|
cwd?: { root?: string; path: string };
|
||||||
|
};
|
||||||
|
defaultInput?: string;
|
||||||
|
promptPrefix?: string;
|
||||||
|
cwd?: { root?: string; path: string };
|
||||||
|
autoSend?: boolean;
|
||||||
|
chat?: UsePiChatType;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useEmbeddableChat(params: UseEmbeddableChatParams) {
|
||||||
|
const { initialMessage, defaultInput = '', promptPrefix, cwd, autoSend = false, chat: externalChat } = params;
|
||||||
|
|
||||||
|
const internalChat = usePiChat(params.sessionId, params.initialModel);
|
||||||
|
const chat = externalChat ?? internalChat;
|
||||||
|
|
||||||
|
const {
|
||||||
|
messages,
|
||||||
|
streamingText,
|
||||||
|
isConnected,
|
||||||
|
isGenerating,
|
||||||
|
sessionId,
|
||||||
|
model,
|
||||||
|
selectedModel,
|
||||||
|
setSelectedModel,
|
||||||
|
sendPrompt,
|
||||||
|
stopGeneration,
|
||||||
|
} = chat;
|
||||||
|
|
||||||
|
const availableModels = useVisiblePiModels();
|
||||||
|
const attachmentManager = useAttachments({ sessionId });
|
||||||
|
const slashCommands = useSlashCommands({ sessionId });
|
||||||
|
|
||||||
|
const [input, setInput] = useState(defaultInput);
|
||||||
|
const [showJumpToBottom, setShowJumpToBottom] = useState(false);
|
||||||
|
const [commandFeedback, setCommandFeedback] = useState<string | null>(null);
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||||
|
const scrollViewportRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const bottomRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const userScrolledRef = useRef(false);
|
||||||
|
|
||||||
|
const handleSend = async () => {
|
||||||
|
const text = input.trim();
|
||||||
|
if (!text || isGenerating) return;
|
||||||
|
|
||||||
|
// Handle slash commands
|
||||||
|
if (text.startsWith('/')) {
|
||||||
|
const result = await slashCommands.execute(text);
|
||||||
|
if (result.handled) {
|
||||||
|
setCommandFeedback(result.feedback);
|
||||||
|
setInput('');
|
||||||
|
if (textareaRef.current) textareaRef.current.style.height = 'auto';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setCommandFeedback(null);
|
||||||
|
|
||||||
|
const { prefix, ids, images } = attachmentManager.processAttachments();
|
||||||
|
let prompt = promptPrefix ? `${promptPrefix}\n\n${text}` : text;
|
||||||
|
if (prefix) prompt = `${prefix}${prompt}`;
|
||||||
|
|
||||||
|
const cwdForFirst = !sessionId ? cwd : undefined;
|
||||||
|
sendPrompt(
|
||||||
|
prompt,
|
||||||
|
!sessionId && ids.length > 0 ? ids : undefined,
|
||||||
|
images.length > 0 ? images : undefined,
|
||||||
|
cwdForFirst,
|
||||||
|
);
|
||||||
|
|
||||||
|
attachmentManager.clearAttachments();
|
||||||
|
setInput('');
|
||||||
|
userScrolledRef.current = false;
|
||||||
|
if (textareaRef.current) textareaRef.current.style.height = 'auto';
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (ev: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
|
if (ev.key === 'Enter' && !ev.shiftKey) {
|
||||||
|
ev.preventDefault();
|
||||||
|
handleSend();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const jumpToBottom = () => {
|
||||||
|
userScrolledRef.current = false;
|
||||||
|
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
};
|
||||||
|
|
||||||
|
const appendToInput = (text: string) => {
|
||||||
|
setInput((prev) => prev + (prev.length > 0 ? ' ' : '') + text);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Auto-resize textarea
|
||||||
|
useEffect(() => {
|
||||||
|
const textarea = textareaRef.current;
|
||||||
|
if (!textarea) return;
|
||||||
|
textarea.style.height = 'auto';
|
||||||
|
textarea.style.height = Math.min(textarea.scrollHeight, 200) + 'px';
|
||||||
|
}, [input]);
|
||||||
|
|
||||||
|
// Auto-scroll to bottom on new messages
|
||||||
|
useEffect(() => {
|
||||||
|
if (!userScrolledRef.current) {
|
||||||
|
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
}, [messages, streamingText]);
|
||||||
|
|
||||||
|
// Detect user scrolling up
|
||||||
|
useEffect(() => {
|
||||||
|
const viewport = scrollViewportRef.current;
|
||||||
|
if (!viewport) return;
|
||||||
|
|
||||||
|
const handleScroll = () => {
|
||||||
|
const { scrollTop, scrollHeight, clientHeight } = viewport;
|
||||||
|
const atBottom = scrollHeight - scrollTop - clientHeight < 60;
|
||||||
|
userScrolledRef.current = !atBottom;
|
||||||
|
setShowJumpToBottom(!atBottom);
|
||||||
|
};
|
||||||
|
|
||||||
|
viewport.addEventListener('scroll', handleScroll);
|
||||||
|
return () => viewport.removeEventListener('scroll', handleScroll);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Focus textarea on mount
|
||||||
|
useEffect(() => {
|
||||||
|
textareaRef.current?.focus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Auto-send initial message
|
||||||
|
const initialSentRef = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialMessage && isConnected && !initialSentRef.current) {
|
||||||
|
initialSentRef.current = true;
|
||||||
|
if (params.initialModel) setSelectedModel(params.initialModel);
|
||||||
|
sendPrompt(
|
||||||
|
initialMessage.text,
|
||||||
|
initialMessage.attachmentIds,
|
||||||
|
initialMessage.images,
|
||||||
|
initialMessage.cwd,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [initialMessage, isConnected]);
|
||||||
|
|
||||||
|
// Auto-send when autoSend is enabled
|
||||||
|
const autoSentRef = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (autoSend && isConnected && !messages.length && input.trim() && !autoSentRef.current) {
|
||||||
|
autoSentRef.current = true;
|
||||||
|
handleSend();
|
||||||
|
}
|
||||||
|
}, [autoSend, isConnected, messages.length, input]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
messages,
|
||||||
|
streamingText,
|
||||||
|
isConnected,
|
||||||
|
isGenerating,
|
||||||
|
sessionId,
|
||||||
|
model,
|
||||||
|
selectedModel,
|
||||||
|
setSelectedModel,
|
||||||
|
sendPrompt,
|
||||||
|
stopGeneration,
|
||||||
|
input,
|
||||||
|
setInput,
|
||||||
|
handleSend,
|
||||||
|
handleKeyDown,
|
||||||
|
appendToInput,
|
||||||
|
attachments: attachmentManager.attachments,
|
||||||
|
attachWebpage: attachmentManager.attachWebpage,
|
||||||
|
attachImage: attachmentManager.attachImage,
|
||||||
|
removeAttachment: attachmentManager.removeAttachment,
|
||||||
|
availableModels,
|
||||||
|
showJumpToBottom,
|
||||||
|
jumpToBottom,
|
||||||
|
commandFeedback,
|
||||||
|
textareaRef,
|
||||||
|
scrollViewportRef,
|
||||||
|
bottomRef,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UseEmbeddableChatType = ReturnType<typeof useEmbeddableChat>;
|
||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
import { Loader2, Image, Link, X } from 'lucide-react';
|
import { Loader2, Image, Link, X } from 'lucide-react';
|
||||||
import type { Attachment } from './types';
|
import type { Attachment } from '../types';
|
||||||
|
|
||||||
type AttachmentListProps = {
|
type AttachmentListProps = {
|
||||||
attachments: Attachment[];
|
attachments: Attachment[];
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Loader2, Mic, Send, Square } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import type { UseEmbeddableChatType } from '../EmbeddableChat/useEmbeddableChat';
|
||||||
|
import { useAudioRecording } from '../useAudioRecording';
|
||||||
|
import { ModelSelector } from './ModelSelector';
|
||||||
|
import { AttachmentList } from './AttachmentList';
|
||||||
|
import { AttachButton } from './AttachButton';
|
||||||
|
import { WebpageDialog } from './WebpageDialog';
|
||||||
|
|
||||||
|
type InputAreaProps = {
|
||||||
|
manager: UseEmbeddableChatType;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const InputArea = ({ manager }: InputAreaProps) => {
|
||||||
|
const {
|
||||||
|
input,
|
||||||
|
setInput,
|
||||||
|
handleSend,
|
||||||
|
handleKeyDown,
|
||||||
|
stopGeneration,
|
||||||
|
isGenerating,
|
||||||
|
isConnected,
|
||||||
|
commandFeedback,
|
||||||
|
textareaRef,
|
||||||
|
messages,
|
||||||
|
availableModels,
|
||||||
|
selectedModel,
|
||||||
|
setSelectedModel,
|
||||||
|
model,
|
||||||
|
attachments,
|
||||||
|
attachWebpage,
|
||||||
|
attachImage,
|
||||||
|
removeAttachment,
|
||||||
|
appendToInput,
|
||||||
|
} = manager;
|
||||||
|
|
||||||
|
const { recording, transcribing, toggleRecording } = useAudioRecording(appendToInput);
|
||||||
|
const [urlDialogOpen, setUrlDialogOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="shrink-0 border-t border-duck-dark/10 bg-background/60 p-2 md:p-3">
|
||||||
|
{commandFeedback && (
|
||||||
|
<div className="mb-2 px-3 py-1.5 text-xs text-duck-teal bg-duck-teal/10 rounded-md">{commandFeedback}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<AttachmentList attachments={attachments} onRemove={removeAttachment} />
|
||||||
|
|
||||||
|
<div className="flex items-end gap-1 md:gap-2">
|
||||||
|
<AttachButton onAttachImage={attachImage} onAttachWebpage={() => setUrlDialogOpen(true)} />
|
||||||
|
<MicButton recording={recording} transcribing={transcribing} onToggle={toggleRecording} />
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
value={input}
|
||||||
|
onChange={(ev) => setInput(ev.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onPaste={(ev) => {
|
||||||
|
const items = ev.clipboardData?.items;
|
||||||
|
if (!items) return;
|
||||||
|
for (const item of Array.from(items)) {
|
||||||
|
if (item.type.startsWith('image/')) {
|
||||||
|
ev.preventDefault();
|
||||||
|
const file = item.getAsFile();
|
||||||
|
if (file) attachImage(file);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="Type a message..."
|
||||||
|
rows={1}
|
||||||
|
className="min-w-0 flex-1 resize-none rounded-lg border border-duck-dark/20 bg-background/80 px-2 py-1.5 md:px-3 md:py-2 text-base md:text-sm text-duck-dark placeholder:text-duck-dark/30 focus:outline-none focus:ring-2 focus:ring-duck-teal/30 focus:border-duck-teal/50"
|
||||||
|
/>
|
||||||
|
{isGenerating ? (
|
||||||
|
<Button onClick={stopGeneration} variant="destructive" size="icon" className="shrink-0 h-7 w-7 md:h-9 md:w-9 cursor-pointer">
|
||||||
|
<Square className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
onClick={handleSend}
|
||||||
|
disabled={!input.trim() || !isConnected}
|
||||||
|
size="icon"
|
||||||
|
className="shrink-0 h-7 w-7 md:h-9 md:w-9 bg-duck-teal hover:bg-duck-teal/90 cursor-pointer disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<Send className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<ModelSelector
|
||||||
|
messages={messages}
|
||||||
|
availableModels={availableModels}
|
||||||
|
selectedModel={selectedModel}
|
||||||
|
onModelChange={setSelectedModel}
|
||||||
|
model={model}
|
||||||
|
isConnected={isConnected}
|
||||||
|
isGenerating={isGenerating}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<WebpageDialog open={urlDialogOpen} onOpenChange={setUrlDialogOpen} onSubmit={attachWebpage} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Helpers ──
|
||||||
|
|
||||||
|
type MicButtonProps = {
|
||||||
|
recording: boolean;
|
||||||
|
transcribing: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MicButton = ({ recording, transcribing, onToggle }: MicButtonProps) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={transcribing}
|
||||||
|
onClick={onToggle}
|
||||||
|
className="relative shrink-0 h-7 w-7 md:h-9 md:w-9 flex items-center justify-center rounded-lg text-duck-dark/40 hover:text-duck-dark/70 hover:bg-duck-dark/5 transition-colors cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{transcribing ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : recording ? (
|
||||||
|
<>
|
||||||
|
<span className="absolute inset-0 rounded-lg animate-ping bg-red-400/30" />
|
||||||
|
<Square className="h-3.5 w-3.5 text-red-500" />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Mic className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
import ReactMarkdown from 'react-markdown';
|
import ReactMarkdown from 'react-markdown';
|
||||||
import remarkGfm from 'remark-gfm';
|
import remarkGfm from 'remark-gfm';
|
||||||
import rehypeRaw from 'rehype-raw';
|
import rehypeRaw from 'rehype-raw';
|
||||||
import type { ChatMessage } from './types';
|
import type { ChatMessage } from '../types';
|
||||||
import { ToolActivity } from './ToolActivity';
|
import { ToolActivity } from './ToolActivity';
|
||||||
import { QuestionActivity } from './QuestionActivity';
|
import { QuestionActivity } from './QuestionActivity';
|
||||||
|
|
||||||
+11
-23
@@ -1,33 +1,21 @@
|
|||||||
import { ArrowDown } from 'lucide-react';
|
import { ArrowDown } from 'lucide-react';
|
||||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||||
import type { ChatMessage } from './types';
|
import type { UseEmbeddableChatType } from '../EmbeddableChat/useEmbeddableChat';
|
||||||
import { MessageBubble, StreamingBubble } from './MessageBubble';
|
import { MessageBubble, StreamingBubble } from './MessageBubble';
|
||||||
|
|
||||||
const OVERSCAN = 5;
|
const OVERSCAN = 5;
|
||||||
|
|
||||||
export const MessageList = ({
|
type MessageListProps = {
|
||||||
messages,
|
manager: UseEmbeddableChatType;
|
||||||
streamingText,
|
};
|
||||||
isGenerating,
|
|
||||||
showJumpToBottom,
|
export const MessageList = ({ manager }: MessageListProps) => {
|
||||||
onJumpToBottom,
|
const { messages, streamingText, isGenerating, showJumpToBottom, jumpToBottom, sendPrompt, scrollViewportRef, bottomRef } = manager;
|
||||||
onQuestionAnswer,
|
|
||||||
scrollViewportRef,
|
|
||||||
bottomRef,
|
|
||||||
}: {
|
|
||||||
messages: ChatMessage[];
|
|
||||||
streamingText: string;
|
|
||||||
isGenerating: boolean;
|
|
||||||
showJumpToBottom: boolean;
|
|
||||||
onJumpToBottom: () => void;
|
|
||||||
onQuestionAnswer?: (text: string) => void;
|
|
||||||
scrollViewportRef: React.RefObject<HTMLDivElement | null>;
|
|
||||||
bottomRef: React.RefObject<HTMLDivElement | null>;
|
|
||||||
}) => {
|
|
||||||
const virtualizer = useVirtualizer({
|
const virtualizer = useVirtualizer({
|
||||||
count: messages.length,
|
count: messages.length,
|
||||||
getScrollElement: () => scrollViewportRef.current,
|
getScrollElement: () => scrollViewportRef.current,
|
||||||
estimateSize: () => 150, // Initial estimate, will be measured
|
estimateSize: () => 150,
|
||||||
overscan: OVERSCAN,
|
overscan: OVERSCAN,
|
||||||
measureElement: (element) => element.getBoundingClientRect().height,
|
measureElement: (element) => element.getBoundingClientRect().height,
|
||||||
});
|
});
|
||||||
@@ -58,7 +46,7 @@ export const MessageList = ({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="py-1.5">
|
<div className="py-1.5">
|
||||||
<MessageBubble message={msg} onAnswer={onQuestionAnswer} />
|
<MessageBubble message={msg} onAnswer={(text) => sendPrompt(text)} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -75,7 +63,7 @@ export const MessageList = ({
|
|||||||
|
|
||||||
{showJumpToBottom && (
|
{showJumpToBottom && (
|
||||||
<button
|
<button
|
||||||
onClick={onJumpToBottom}
|
onClick={jumpToBottom}
|
||||||
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"
|
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" />
|
<ArrowDown className="h-4 w-4" />
|
||||||
+2
-6
@@ -1,7 +1,6 @@
|
|||||||
import { useMemo } from 'react';
|
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
import type { ModelOption } from '@/state/useModels';
|
import type { ModelOption } from '@/state/useModels';
|
||||||
import type { ChatMessage } from './types';
|
import type { ChatMessage } from '../types';
|
||||||
|
|
||||||
const PROVIDER_DISPLAY: Record<string, string> = {
|
const PROVIDER_DISPLAY: Record<string, string> = {
|
||||||
anthropic: 'Anthropic',
|
anthropic: 'Anthropic',
|
||||||
@@ -39,10 +38,7 @@ export function ModelSelector({
|
|||||||
isConnected,
|
isConnected,
|
||||||
isGenerating,
|
isGenerating,
|
||||||
}: ModelSelectorProps) {
|
}: ModelSelectorProps) {
|
||||||
const providers = useMemo(
|
const providers = [...new Set(availableModels.map((m) => m.provider).filter(Boolean))] as string[];
|
||||||
() => [...new Set(availableModels.map((m) => m.provider).filter(Boolean))] as string[],
|
|
||||||
[availableModels],
|
|
||||||
);
|
|
||||||
|
|
||||||
const activeProvider = availableModels.find((m) => m.id === selectedModel)?.provider ?? providers[0];
|
const activeProvider = availableModels.find((m) => m.id === selectedModel)?.provider ?? providers[0];
|
||||||
const providerModels = availableModels.filter((m) => m.provider === activeProvider);
|
const providerModels = availableModels.filter((m) => m.provider === activeProvider);
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { MessageCircleQuestion, Check } from 'lucide-react';
|
import { MessageCircleQuestion, Check } from 'lucide-react';
|
||||||
import type { ChatMessage } from './types';
|
import type { ChatMessage } from '../types';
|
||||||
|
|
||||||
type ToolMessage = Extract<ChatMessage, { role: 'tool' }>;
|
type ToolMessage = Extract<ChatMessage, { role: 'tool' }>;
|
||||||
|
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { FileText, Terminal, Pencil, Search, Globe, Wrench, ChevronRight } from 'lucide-react';
|
import { FileText, Terminal, Pencil, Search, Globe, Wrench, ChevronRight } from 'lucide-react';
|
||||||
import type { ChatMessage } from './types';
|
import type { ChatMessage } from '../types';
|
||||||
|
|
||||||
type ToolMessage = Extract<ChatMessage, { role: 'tool' }>;
|
type ToolMessage = Extract<ChatMessage, { role: 'tool' }>;
|
||||||
|
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
export { MessageList } from './components/MessageList';
|
||||||
|
export { MessageBubble, StreamingBubble } from './components/MessageBubble';
|
||||||
|
export { ToolActivity } from './components/ToolActivity';
|
||||||
|
export { QuestionActivity } from './components/QuestionActivity';
|
||||||
|
export { ModelSelector } from './components/ModelSelector';
|
||||||
|
export { InputArea } from './components/InputArea';
|
||||||
|
export { ChatLauncher } from './ChatLauncher';
|
||||||
|
export { AttachmentList } from './components/AttachmentList';
|
||||||
|
export { AttachButton } from './components/AttachButton';
|
||||||
|
export { WebpageDialog } from './components/WebpageDialog';
|
||||||
|
export { EmbeddableChat, type UseEmbeddableChatType } from './EmbeddableChat';
|
||||||
|
export { usePiChat, type UsePiChatType } from '../../hooks/usePiChat';
|
||||||
|
export { ChatList } from './ChatList';
|
||||||
|
export { useSlashCommands } from './useSlashCommands';
|
||||||
|
export { useChatSessions, type UseChatSessionsType } from './useChatSessions';
|
||||||
|
export { useChatSession, type UseChatSessionType } from './useChatSession';
|
||||||
|
export { useAttachments, type UseAttachmentsType } from './useAttachments';
|
||||||
|
export { useAudioRecording, type UseAudioRecordingType } from './useAudioRecording';
|
||||||
|
|
||||||
|
export * from './types';
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { useClient } from 'hooks/useClient';
|
||||||
|
import type { Attachment } from './types';
|
||||||
|
|
||||||
|
type UseAttachmentsParams = {
|
||||||
|
sessionId?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useAttachments(params?: UseAttachmentsParams) {
|
||||||
|
const client = useClient();
|
||||||
|
const [attachments, setAttachments] = useState<Attachment[]>([]);
|
||||||
|
|
||||||
|
const attachWebpage = async (url: string) => {
|
||||||
|
const idx = attachments.length;
|
||||||
|
setAttachments((prev) => [
|
||||||
|
...prev,
|
||||||
|
{ type: 'webpage', url, title: '', content: '', attachmentId: '', loading: true },
|
||||||
|
]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await client.post<{ url: string; title: string; content: string; attachmentId: string }>('/scrape', {
|
||||||
|
url,
|
||||||
|
...(params?.sessionId ? { sessionId: params.sessionId } : {}),
|
||||||
|
provider: 'pi-mono',
|
||||||
|
});
|
||||||
|
setAttachments((prev) =>
|
||||||
|
prev.map((a, i) =>
|
||||||
|
i === idx
|
||||||
|
? { ...a, title: res.title, content: res.content, attachmentId: res.attachmentId, loading: false }
|
||||||
|
: a,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
setAttachments((prev) => prev.filter((_, i) => i !== idx));
|
||||||
|
toast.error('Failed to scrape webpage');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const attachImage = async (file: File) => {
|
||||||
|
const idx = attachments.length;
|
||||||
|
setAttachments((prev) => [
|
||||||
|
...prev,
|
||||||
|
{ type: 'image', filename: file.name, dataUrl: '', attachmentId: '', loading: true },
|
||||||
|
]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
if (params?.sessionId) formData.append('sessionId', params.sessionId);
|
||||||
|
formData.append('provider', 'pi-mono');
|
||||||
|
|
||||||
|
const res = await client.post<{ filename: string; dataUrl: string; attachmentId: string }>('/upload', formData);
|
||||||
|
setAttachments((prev) =>
|
||||||
|
prev.map((a, i) =>
|
||||||
|
i === idx ? { ...a, dataUrl: res.dataUrl, attachmentId: res.attachmentId, loading: false } : a,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
setAttachments((prev) => prev.filter((_, i) => i !== idx));
|
||||||
|
toast.error('Failed to upload image');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeAttachment = (index: number) => {
|
||||||
|
setAttachments((prev) => prev.filter((_, i) => i !== index));
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearAttachments = () => {
|
||||||
|
setAttachments([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const processAttachments = () => {
|
||||||
|
let prefix = '';
|
||||||
|
const ids: string[] = [];
|
||||||
|
const images: { filename: string; dataUrl: string }[] = [];
|
||||||
|
|
||||||
|
for (const a of attachments) {
|
||||||
|
if (a.loading) continue;
|
||||||
|
if (a.type === 'webpage' && a.content) {
|
||||||
|
prefix = `[Attached webpage: ${a.url}]\n${a.content}\n\n${prefix}`;
|
||||||
|
} else if (a.type === 'image' && a.dataUrl) {
|
||||||
|
prefix = `[Attached image: ${a.filename}]\n\n${prefix}`;
|
||||||
|
images.push({ filename: a.filename, dataUrl: a.dataUrl });
|
||||||
|
}
|
||||||
|
ids.push(a.attachmentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { prefix, ids, images };
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
attachments,
|
||||||
|
attachWebpage,
|
||||||
|
attachImage,
|
||||||
|
removeAttachment,
|
||||||
|
clearAttachments,
|
||||||
|
processAttachments,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UseAttachmentsType = ReturnType<typeof useAttachments>;
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import { useState, useRef } from 'react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
export function useAudioRecording(onTranscription: (text: string) => void) {
|
||||||
|
const [recording, setRecording] = useState(false);
|
||||||
|
const [transcribing, setTranscribing] = useState(false);
|
||||||
|
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||||
|
const chunksRef = useRef<Blob[]>([]);
|
||||||
|
|
||||||
|
const toggleRecording = async () => {
|
||||||
|
if (recording) {
|
||||||
|
const recorder = mediaRecorderRef.current;
|
||||||
|
if (!recorder) return;
|
||||||
|
|
||||||
|
setRecording(false);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (recorder.state === 'inactive') {
|
||||||
|
recorder.stream.getTracks().forEach((t) => t.stop());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await new Promise<Blob>((resolve, reject) => {
|
||||||
|
const timeout = setTimeout(() => reject(new Error('Recording stop timed out')), 5000);
|
||||||
|
recorder.onstop = () => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
resolve(new Blob(chunksRef.current, { type: recorder.mimeType }));
|
||||||
|
chunksRef.current = [];
|
||||||
|
};
|
||||||
|
recorder.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
recorder.stream.getTracks().forEach((t) => t.stop());
|
||||||
|
|
||||||
|
if (blob.size === 0) {
|
||||||
|
toast.error('No audio was captured');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setTranscribing(true);
|
||||||
|
try {
|
||||||
|
const wav = await blobToWav(blob);
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', wav, 'recording.wav');
|
||||||
|
formData.append('temperature', '0.0');
|
||||||
|
formData.append('temperature_inc', '0.2');
|
||||||
|
formData.append('response_format', 'json');
|
||||||
|
|
||||||
|
const res = await fetch('http://macmini:8178/inference', { method: 'POST', body: formData });
|
||||||
|
if (!res.ok) throw new Error(`Whisper returned ${res.status}`);
|
||||||
|
const json = await res.json();
|
||||||
|
if (json.error) throw new Error(json.error);
|
||||||
|
const text = (json.text ?? '').trim();
|
||||||
|
if (text) onTranscription(text);
|
||||||
|
} finally {
|
||||||
|
setTranscribing(false);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
recorder.stream?.getTracks().forEach((t) => t.stop());
|
||||||
|
chunksRef.current = [];
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Recording failed');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||||
|
const recorder = new MediaRecorder(stream);
|
||||||
|
mediaRecorderRef.current = recorder;
|
||||||
|
chunksRef.current = [];
|
||||||
|
|
||||||
|
recorder.ondataavailable = (ev) => {
|
||||||
|
if (ev.data.size > 0) chunksRef.current.push(ev.data);
|
||||||
|
};
|
||||||
|
|
||||||
|
recorder.start(250);
|
||||||
|
setRecording(true);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Could not access microphone');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return { recording, transcribing, toggleRecording };
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UseAudioRecordingType = ReturnType<typeof useAudioRecording>;
|
||||||
|
|
||||||
|
// ── Helpers ──
|
||||||
|
|
||||||
|
const blobToWav = async (blob: Blob): Promise<Blob> => {
|
||||||
|
const ctx = new AudioContext();
|
||||||
|
const buf = await ctx.decodeAudioData(await blob.arrayBuffer());
|
||||||
|
await ctx.close();
|
||||||
|
|
||||||
|
const samples = buf.getChannelData(0);
|
||||||
|
const len = samples.length;
|
||||||
|
const sr = buf.sampleRate;
|
||||||
|
const ab = new ArrayBuffer(44 + len * 2);
|
||||||
|
const v = new DataView(ab);
|
||||||
|
|
||||||
|
const s = (o: number, str: string) => {
|
||||||
|
for (let i = 0; i < str.length; i++) v.setUint8(o + i, str.charCodeAt(i));
|
||||||
|
};
|
||||||
|
s(0, 'RIFF');
|
||||||
|
v.setUint32(4, 36 + len * 2, true);
|
||||||
|
s(8, 'WAVE');
|
||||||
|
s(12, 'fmt ');
|
||||||
|
v.setUint32(16, 16, true);
|
||||||
|
v.setUint16(20, 1, true);
|
||||||
|
v.setUint16(22, 1, true);
|
||||||
|
v.setUint32(24, sr, true);
|
||||||
|
v.setUint32(28, sr * 2, true);
|
||||||
|
v.setUint16(32, 2, true);
|
||||||
|
v.setUint16(34, 16, true);
|
||||||
|
s(36, 'data');
|
||||||
|
v.setUint32(40, len * 2, true);
|
||||||
|
|
||||||
|
for (let i = 0; i < len; i++) {
|
||||||
|
const val = Math.max(-1, Math.min(1, samples[i]!));
|
||||||
|
v.setInt16(44 + i * 2, val < 0 ? val * 0x8000 : val * 0x7fff, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Blob([ab], { type: 'audio/wav' });
|
||||||
|
};
|
||||||
+24
-30
@@ -1,9 +1,9 @@
|
|||||||
import { useCallback, useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import Editor, { type OnMount } from '@monaco-editor/react';
|
import Editor, { type OnMount } from '@monaco-editor/react';
|
||||||
import type { editor as MonacoEditor } from 'monaco-editor';
|
import type { editor as MonacoEditor } from 'monaco-editor';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from '@/components/ui/resizable';
|
import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from '@/components/ui/resizable';
|
||||||
import { useFiles } from 'apps/FileBrowser';
|
import { useFilesAPI } from '../../hooks/useFilesAPI';
|
||||||
import { FileTree } from './FileTree';
|
import { FileTree } from './FileTree';
|
||||||
import { EditorTabs } from './EditorTabs';
|
import { EditorTabs } from './EditorTabs';
|
||||||
import { useEditorState } from './useEditorState';
|
import { useEditorState } from './useEditorState';
|
||||||
@@ -19,29 +19,26 @@ type CodeEditorViewProps = {
|
|||||||
export const CodeEditorView = ({ className, theme = 'vs-dark', root = 'home', initialPath }: CodeEditorViewProps) => {
|
export const CodeEditorView = ({ className, theme = 'vs-dark', root = 'home', initialPath }: CodeEditorViewProps) => {
|
||||||
const { files, activePath, setActivePath, openFile, closeFile, setContent, markSaved, getActiveFile } =
|
const { files, activePath, setActivePath, openFile, closeFile, setContent, markSaved, getActiveFile } =
|
||||||
useEditorState();
|
useEditorState();
|
||||||
const { readFile, writeFile } = useFiles(root);
|
const { readFile, writeFile } = useFilesAPI(root);
|
||||||
const editorRef = useRef<MonacoEditor.IStandaloneCodeEditor | null>(null);
|
const editorRef = useRef<MonacoEditor.IStandaloneCodeEditor | null>(null);
|
||||||
|
|
||||||
const activeFile = getActiveFile();
|
const activeFile = getActiveFile();
|
||||||
|
|
||||||
const handleOpenFile = useCallback(
|
const handleOpenFile = async (path: string, name: string) => {
|
||||||
async (path: string, name: string) => {
|
const existing = files.find((f) => f.path === path);
|
||||||
const existing = files.find((f) => f.path === path);
|
if (existing) {
|
||||||
if (existing) {
|
setActivePath(path);
|
||||||
setActivePath(path);
|
return;
|
||||||
return;
|
}
|
||||||
}
|
try {
|
||||||
try {
|
const res = await readFile(path);
|
||||||
const res = await readFile(path);
|
openFile(path, name, res.content);
|
||||||
openFile(path, name, res.content);
|
} catch {
|
||||||
} catch {
|
toast.error('Failed to read file');
|
||||||
toast.error('Failed to read file');
|
}
|
||||||
}
|
};
|
||||||
},
|
|
||||||
[files, setActivePath, readFile, openFile],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleSave = useCallback(async () => {
|
const handleSave = async () => {
|
||||||
if (!activeFile || !activeFile.isDirty) return;
|
if (!activeFile || !activeFile.isDirty) return;
|
||||||
try {
|
try {
|
||||||
await writeFile(activeFile.path, activeFile.content);
|
await writeFile(activeFile.path, activeFile.content);
|
||||||
@@ -50,17 +47,14 @@ export const CodeEditorView = ({ className, theme = 'vs-dark', root = 'home', in
|
|||||||
} catch {
|
} catch {
|
||||||
toast.error('Failed to save file');
|
toast.error('Failed to save file');
|
||||||
}
|
}
|
||||||
}, [activeFile, writeFile, markSaved]);
|
};
|
||||||
|
|
||||||
const handleEditorMount: OnMount = useCallback(
|
const handleEditorMount: OnMount = (editor, monaco) => {
|
||||||
(editor, monaco) => {
|
editorRef.current = editor;
|
||||||
editorRef.current = editor;
|
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => {
|
||||||
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => {
|
handleSave();
|
||||||
handleSave();
|
});
|
||||||
});
|
};
|
||||||
},
|
|
||||||
[handleSave],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = (ev: KeyboardEvent) => {
|
const handler = (ev: KeyboardEvent) => {
|
||||||
+1
-2
@@ -1,4 +1,3 @@
|
|||||||
import { useMemo } from 'react';
|
|
||||||
import { X } from 'lucide-react';
|
import { X } from 'lucide-react';
|
||||||
import { getIcon } from 'material-file-icons';
|
import { getIcon } from 'material-file-icons';
|
||||||
import type { OpenFile } from './useEditorState';
|
import type { OpenFile } from './useEditorState';
|
||||||
@@ -12,7 +11,7 @@ type EditorTabsProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const FileIcon = ({ name }: { name: string }) => {
|
const FileIcon = ({ name }: { name: string }) => {
|
||||||
const svg = useMemo(() => getIcon(name).svg, [name]);
|
const svg = getIcon(name).svg;
|
||||||
return <span className="inline-flex h-4 w-4 shrink-0" dangerouslySetInnerHTML={{ __html: svg }} />;
|
return <span className="inline-flex h-4 w-4 shrink-0" dangerouslySetInnerHTML={{ __html: svg }} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
+9
-9
@@ -1,7 +1,7 @@
|
|||||||
import { useState, useMemo, useCallback } from 'react';
|
import { useState } from 'react';
|
||||||
import { ChevronRight, ChevronDown, Folder } from 'lucide-react';
|
import { ChevronRight, ChevronDown, Folder } from 'lucide-react';
|
||||||
import { getIcon } from 'material-file-icons';
|
import { getIcon } from 'material-file-icons';
|
||||||
import { useFiles, type DirEntry } from 'apps/FileBrowser';
|
import { useFilesAPI, type DirEntry } from '../../hooks/useFilesAPI';
|
||||||
|
|
||||||
type FileTreeProps = {
|
type FileTreeProps = {
|
||||||
root: string;
|
root: string;
|
||||||
@@ -18,7 +18,7 @@ type TreeNodeProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const FileIcon = ({ name }: { name: string }) => {
|
const FileIcon = ({ name }: { name: string }) => {
|
||||||
const svg = useMemo(() => getIcon(name).svg, [name]);
|
const svg = getIcon(name).svg;
|
||||||
return <span className="inline-flex h-4 w-4 shrink-0" dangerouslySetInnerHTML={{ __html: svg }} />;
|
return <span className="inline-flex h-4 w-4 shrink-0" dangerouslySetInnerHTML={{ __html: svg }} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -32,11 +32,11 @@ const TreeNode = ({ entry, parentPath, root, onOpenFile, depth }: TreeNodeProps)
|
|||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
const [children, setChildren] = useState<DirEntry[] | null>(null);
|
const [children, setChildren] = useState<DirEntry[] | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const { listDir } = useFiles(root);
|
const { listDir } = useFilesAPI(root);
|
||||||
const isDir = entry.type === 'directory';
|
const isDir = entry.type === 'directory';
|
||||||
const fullPath = parentPath === '/' ? `/${entry.name}` : `${parentPath}/${entry.name}`;
|
const fullPath = parentPath === '/' ? `/${entry.name}` : `${parentPath}/${entry.name}`;
|
||||||
|
|
||||||
const handleClick = useCallback(async () => {
|
const handleClick = async () => {
|
||||||
if (!isDir) {
|
if (!isDir) {
|
||||||
onOpenFile(fullPath, entry.name);
|
onOpenFile(fullPath, entry.name);
|
||||||
return;
|
return;
|
||||||
@@ -52,7 +52,7 @@ const TreeNode = ({ entry, parentPath, root, onOpenFile, depth }: TreeNodeProps)
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
setExpanded((prev) => !prev);
|
setExpanded((prev) => !prev);
|
||||||
}, [isDir, expanded, children, fullPath, entry.name, listDir, onOpenFile]);
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -98,11 +98,11 @@ const TreeNode = ({ entry, parentPath, root, onOpenFile, depth }: TreeNodeProps)
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const FileTree = ({ root, basePath, onOpenFile }: FileTreeProps) => {
|
export const FileTree = ({ root, basePath, onOpenFile }: FileTreeProps) => {
|
||||||
const { listDir } = useFiles(root);
|
const { listDir } = useFilesAPI(root);
|
||||||
const [entries, setEntries] = useState<DirEntry[] | null>(null);
|
const [entries, setEntries] = useState<DirEntry[] | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
const loadRoot = useCallback(async () => {
|
const loadRoot = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await listDir(basePath);
|
const res = await listDir(basePath);
|
||||||
@@ -111,7 +111,7 @@ export const FileTree = ({ root, basePath, onOpenFile }: FileTreeProps) => {
|
|||||||
setEntries([]);
|
setEntries([]);
|
||||||
}
|
}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}, [listDir, basePath]);
|
};
|
||||||
|
|
||||||
if (entries === null && !loading) {
|
if (entries === null && !loading) {
|
||||||
loadRoot();
|
loadRoot();
|
||||||
+20
-23
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useCallback } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
export type OpenFile = {
|
export type OpenFile = {
|
||||||
path: string;
|
path: string;
|
||||||
@@ -12,45 +12,42 @@ export const useEditorState = () => {
|
|||||||
const [files, setFiles] = useState<OpenFile[]>([]);
|
const [files, setFiles] = useState<OpenFile[]>([]);
|
||||||
const [activePath, setActivePath] = useState<string | null>(null);
|
const [activePath, setActivePath] = useState<string | null>(null);
|
||||||
|
|
||||||
const openFile = useCallback((path: string, name: string, content: string) => {
|
const openFile = (path: string, name: string, content: string) => {
|
||||||
setFiles((prev) => {
|
setFiles((prev) => {
|
||||||
const existing = prev.find((f) => f.path === path);
|
const existing = prev.find((f) => f.path === path);
|
||||||
if (existing) return prev;
|
if (existing) return prev;
|
||||||
return [...prev, { path, name, content, originalContent: content, isDirty: false }];
|
return [...prev, { path, name, content, originalContent: content, isDirty: false }];
|
||||||
});
|
});
|
||||||
setActivePath(path);
|
setActivePath(path);
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
const closeFile = useCallback(
|
const closeFile = (path: string) => {
|
||||||
(path: string) => {
|
setFiles((prev) => {
|
||||||
setFiles((prev) => {
|
const next = prev.filter((f) => f.path !== path);
|
||||||
const next = prev.filter((f) => f.path !== path);
|
if (activePath === path) {
|
||||||
if (activePath === path) {
|
const idx = prev.findIndex((f) => f.path === path);
|
||||||
const idx = prev.findIndex((f) => f.path === path);
|
const newActive = next[Math.min(idx, next.length - 1)] ?? null;
|
||||||
const newActive = next[Math.min(idx, next.length - 1)] ?? null;
|
setActivePath(newActive?.path ?? null);
|
||||||
setActivePath(newActive?.path ?? null);
|
}
|
||||||
}
|
return next;
|
||||||
return next;
|
});
|
||||||
});
|
};
|
||||||
},
|
|
||||||
[activePath],
|
|
||||||
);
|
|
||||||
|
|
||||||
const setContent = useCallback((path: string, content: string) => {
|
const setContent = (path: string, content: string) => {
|
||||||
setFiles((prev) =>
|
setFiles((prev) =>
|
||||||
prev.map((f) => (f.path === path ? { ...f, content, isDirty: content !== f.originalContent } : f)),
|
prev.map((f) => (f.path === path ? { ...f, content, isDirty: content !== f.originalContent } : f)),
|
||||||
);
|
);
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
const markSaved = useCallback((path: string, content: string) => {
|
const markSaved = (path: string, content: string) => {
|
||||||
setFiles((prev) =>
|
setFiles((prev) =>
|
||||||
prev.map((f) => (f.path === path ? { ...f, originalContent: content, content, isDirty: false } : f)),
|
prev.map((f) => (f.path === path ? { ...f, originalContent: content, content, isDirty: false } : f)),
|
||||||
);
|
);
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
const getActiveFile = useCallback((): OpenFile | null => {
|
const getActiveFile = (): OpenFile | null => {
|
||||||
return files.find((f) => f.path === activePath) ?? null;
|
return files.find((f) => f.path === activePath) ?? null;
|
||||||
}, [files, activePath]);
|
};
|
||||||
|
|
||||||
return { files, activePath, setActivePath, openFile, closeFile, setContent, markSaved, getActiveFile };
|
return { files, activePath, setActivePath, openFile, closeFile, setContent, markSaved, getActiveFile };
|
||||||
};
|
};
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { Breadcrumb } from './components/Breadcrumb';
|
||||||
|
import { Toolbar } from './components/Toolbar';
|
||||||
|
import { HomeDirSelector } from './components/HomeDirSelector';
|
||||||
|
import { UploadProgress } from './components/UploadProgress';
|
||||||
|
import { FileViewContainer } from './components/FileViewContainer';
|
||||||
|
import { TaskRunnerDialog } from './components/TaskRunnerDialog';
|
||||||
|
import { VideoDownloadDialog } from './components/VideoDownloadDialog';
|
||||||
|
import { useFileBrowserApp } from './useFileBrowserApp';
|
||||||
|
|
||||||
|
type FileBrowserAppProps = {
|
||||||
|
basePath?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FileBrowserApp = ({ basePath = '/' }: FileBrowserAppProps) => {
|
||||||
|
const fileBrowserManager = useFileBrowserApp(basePath);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full overflow-hidden">
|
||||||
|
<Toolbar fileBrowserManager={fileBrowserManager} />
|
||||||
|
|
||||||
|
<HomeDirSelector fileBrowserManager={fileBrowserManager} basePath={basePath} />
|
||||||
|
|
||||||
|
{/* Breadcrumb */}
|
||||||
|
<div className="shrink-0 border-b border-duck-dark/10 px-4 py-2">
|
||||||
|
<Breadcrumb path={fileBrowserManager.currentPath} onNavigate={fileBrowserManager.handleNavigate} basePath={basePath} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UploadProgress fileBrowserManager={fileBrowserManager} />
|
||||||
|
|
||||||
|
<FileViewContainer fileBrowserManager={fileBrowserManager} />
|
||||||
|
|
||||||
|
<TaskRunnerDialog fileBrowserManager={fileBrowserManager} />
|
||||||
|
<VideoDownloadDialog fileBrowserManager={fileBrowserManager} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
+72
-99
@@ -1,41 +1,16 @@
|
|||||||
import { useRef, useCallback, useMemo, useState, useEffect } from 'react';
|
import { useRef, useState, useEffect } from 'react';
|
||||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||||
import { ChevronUp, ChevronDown } from 'lucide-react';
|
import { ChevronUp, ChevronDown } from 'lucide-react';
|
||||||
import type { DirEntry } from './useFiles';
|
import type { DirEntry } from '../../../../hooks/useFilesAPI';
|
||||||
import type { TaskSummary } from './useTasks';
|
import { getFileType } from '../../../FileViewer';
|
||||||
import { getFileType } from 'apps/FileViewer';
|
import type { UseFileBrowserAppType } from '../useFileBrowserApp';
|
||||||
import { FileItem } from './FileItem';
|
import { FileItem } from './FileItem';
|
||||||
|
|
||||||
type ClipboardState = { paths: string[]; mode: 'copy' | 'cut' } | null;
|
|
||||||
|
|
||||||
type SortField = 'name' | 'size' | 'type' | 'date';
|
type SortField = 'name' | 'size' | 'type' | 'date';
|
||||||
type SortDirection = 'asc' | 'desc';
|
type SortDirection = 'asc' | 'desc';
|
||||||
|
|
||||||
type FileGridProps = {
|
type FileGridProps = {
|
||||||
entries: DirEntry[];
|
fileBrowserManager: UseFileBrowserAppType;
|
||||||
viewMode: 'grid' | 'list';
|
|
||||||
currentPath: string;
|
|
||||||
selected: Set<string>;
|
|
||||||
clipboard: ClipboardState;
|
|
||||||
onOpen: (entry: DirEntry) => void;
|
|
||||||
onDelete: (entry: DirEntry) => void;
|
|
||||||
onRename: (entry: DirEntry, newName: string) => void;
|
|
||||||
onChat: (entry: DirEntry) => void;
|
|
||||||
onDownload: (entry: DirEntry) => void;
|
|
||||||
onSelect: (names: Set<string>) => void;
|
|
||||||
onCut: () => void;
|
|
||||||
onCopy: () => void;
|
|
||||||
renamingName: string | null;
|
|
||||||
onRenamingChange: (name: string | null) => void;
|
|
||||||
onReadAloud: (entry: DirEntry) => void;
|
|
||||||
onOcr: (entry: DirEntry) => void;
|
|
||||||
onTranscribe: (entry: DirEntry) => void;
|
|
||||||
onExtractAudio: (entry: DirEntry) => void;
|
|
||||||
onExtract: (entry: DirEntry) => void;
|
|
||||||
getMatchingTasks: (fileName: string, entryType: 'file' | 'directory') => TaskSummary[];
|
|
||||||
onRunTask: (task: TaskSummary, entry: DirEntry) => void;
|
|
||||||
onCreateWorkspace: (entry: DirEntry) => void;
|
|
||||||
scrollRef: React.RefObject<HTMLDivElement | null>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const LIST_ROW_HEIGHT = 42;
|
const LIST_ROW_HEIGHT = 42;
|
||||||
@@ -67,37 +42,38 @@ const useColumnCount = (scrollRef: React.RefObject<HTMLDivElement | null>) => {
|
|||||||
return cols;
|
return cols;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const FileGrid = ({
|
export const FileGrid = ({ fileBrowserManager }: FileGridProps) => {
|
||||||
entries,
|
const {
|
||||||
viewMode,
|
visibleEntries: entries,
|
||||||
currentPath,
|
viewMode,
|
||||||
selected,
|
currentPath,
|
||||||
clipboard,
|
selected,
|
||||||
onOpen,
|
clipboard,
|
||||||
onDelete,
|
handleOpen,
|
||||||
onRename,
|
handleDelete,
|
||||||
onChat,
|
handleRename,
|
||||||
onDownload,
|
handleChat,
|
||||||
onSelect,
|
handleDownload,
|
||||||
onCut,
|
setSelected,
|
||||||
onCopy,
|
handleCut,
|
||||||
renamingName,
|
handleCopy,
|
||||||
onRenamingChange,
|
renamingName,
|
||||||
onReadAloud,
|
setRenamingName,
|
||||||
onOcr,
|
handleReadAloud,
|
||||||
onTranscribe,
|
handleOcr,
|
||||||
onExtractAudio,
|
handleTranscribe,
|
||||||
onExtract,
|
handleExtractAudio,
|
||||||
getMatchingTasks,
|
handleExtract,
|
||||||
onRunTask,
|
getMatchingTasks,
|
||||||
onCreateWorkspace,
|
handleRunTask,
|
||||||
scrollRef,
|
handleCreateWorkspace,
|
||||||
}: FileGridProps) => {
|
fileScrollRef: scrollRef,
|
||||||
|
} = fileBrowserManager;
|
||||||
const lastClickedIdx = useRef<number>(-1);
|
const lastClickedIdx = useRef<number>(-1);
|
||||||
const [sortField, setSortField] = useState<SortField>('name');
|
const [sortField, setSortField] = useState<SortField>('name');
|
||||||
const [sortDirection, setSortDirection] = useState<SortDirection>('asc');
|
const [sortDirection, setSortDirection] = useState<SortDirection>('asc');
|
||||||
|
|
||||||
const sorted = useMemo(() => {
|
const sorted = (() => {
|
||||||
const compare = (a: DirEntry, b: DirEntry): number => {
|
const compare = (a: DirEntry, b: DirEntry): number => {
|
||||||
if (a.type !== b.type) return a.type === 'directory' ? -1 : 1;
|
if (a.type !== b.type) return a.type === 'directory' ? -1 : 1;
|
||||||
let result: number;
|
let result: number;
|
||||||
@@ -122,7 +98,7 @@ export const FileGrid = ({
|
|||||||
return sortDirection === 'asc' ? result : -result;
|
return sortDirection === 'asc' ? result : -result;
|
||||||
};
|
};
|
||||||
return [...entries].sort(compare);
|
return [...entries].sort(compare);
|
||||||
}, [entries, sortField, sortDirection]);
|
})();
|
||||||
|
|
||||||
const toggleSort = (field: SortField) => {
|
const toggleSort = (field: SortField) => {
|
||||||
if (sortField === field) {
|
if (sortField === field) {
|
||||||
@@ -133,34 +109,31 @@ export const FileGrid = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSelect = useCallback(
|
const handleSelect = (entry: DirEntry, ev: React.MouseEvent) => {
|
||||||
(entry: DirEntry, ev: React.MouseEvent) => {
|
const idx = sorted.findIndex((e) => e.name === entry.name);
|
||||||
const idx = sorted.findIndex((e) => e.name === entry.name);
|
|
||||||
|
|
||||||
if (ev.shiftKey && lastClickedIdx.current >= 0) {
|
if (ev.shiftKey && lastClickedIdx.current >= 0) {
|
||||||
const start = Math.min(lastClickedIdx.current, idx);
|
const start = Math.min(lastClickedIdx.current, idx);
|
||||||
const end = Math.max(lastClickedIdx.current, idx);
|
const end = Math.max(lastClickedIdx.current, idx);
|
||||||
const next = new Set(selected);
|
const next = new Set(selected);
|
||||||
for (let i = start; i <= end; i++) {
|
for (let i = start; i <= end; i++) {
|
||||||
next.add(sorted[i]!.name);
|
next.add(sorted[i]!.name);
|
||||||
}
|
|
||||||
onSelect(next);
|
|
||||||
} else if (ev.ctrlKey || ev.metaKey) {
|
|
||||||
const next = new Set(selected);
|
|
||||||
if (next.has(entry.name)) {
|
|
||||||
next.delete(entry.name);
|
|
||||||
} else {
|
|
||||||
next.add(entry.name);
|
|
||||||
}
|
|
||||||
onSelect(next);
|
|
||||||
lastClickedIdx.current = idx;
|
|
||||||
} else {
|
|
||||||
onSelect(new Set([entry.name]));
|
|
||||||
lastClickedIdx.current = idx;
|
|
||||||
}
|
}
|
||||||
},
|
setSelected(next);
|
||||||
[sorted, selected, onSelect],
|
} else if (ev.ctrlKey || ev.metaKey) {
|
||||||
);
|
const next = new Set(selected);
|
||||||
|
if (next.has(entry.name)) {
|
||||||
|
next.delete(entry.name);
|
||||||
|
} else {
|
||||||
|
next.add(entry.name);
|
||||||
|
}
|
||||||
|
setSelected(next);
|
||||||
|
lastClickedIdx.current = idx;
|
||||||
|
} else {
|
||||||
|
setSelected(new Set([entry.name]));
|
||||||
|
lastClickedIdx.current = idx;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const cols = useColumnCount(scrollRef);
|
const cols = useColumnCount(scrollRef);
|
||||||
const cutPaths = clipboard?.mode === 'cut' ? new Set(clipboard.paths) : new Set<string>();
|
const cutPaths = clipboard?.mode === 'cut' ? new Set(clipboard.paths) : new Set<string>();
|
||||||
@@ -190,24 +163,24 @@ export const FileGrid = ({
|
|||||||
anySelected={anySelected}
|
anySelected={anySelected}
|
||||||
selectedCount={selected.size}
|
selectedCount={selected.size}
|
||||||
isCut={cutPaths.has(entryPath(entry.name))}
|
isCut={cutPaths.has(entryPath(entry.name))}
|
||||||
onOpen={onOpen}
|
onOpen={handleOpen}
|
||||||
onDelete={onDelete}
|
onDelete={handleDelete}
|
||||||
onRename={onRename}
|
onRename={handleRename}
|
||||||
onChat={onChat}
|
onChat={handleChat}
|
||||||
onDownload={onDownload}
|
onDownload={handleDownload}
|
||||||
onSelect={handleSelect}
|
onSelect={handleSelect}
|
||||||
onCut={onCut}
|
onCut={handleCut}
|
||||||
onCopy={onCopy}
|
onCopy={handleCopy}
|
||||||
forceRename={renamingName === entry.name}
|
forceRename={renamingName === entry.name}
|
||||||
onRenamingChange={onRenamingChange}
|
onRenamingChange={setRenamingName}
|
||||||
onReadAloud={onReadAloud}
|
onReadAloud={handleReadAloud}
|
||||||
onOcr={onOcr}
|
onOcr={handleOcr}
|
||||||
onTranscribe={onTranscribe}
|
onTranscribe={handleTranscribe}
|
||||||
onExtractAudio={onExtractAudio}
|
onExtractAudio={handleExtractAudio}
|
||||||
onExtract={onExtract}
|
onExtract={handleExtract}
|
||||||
matchingTasks={getMatchingTasks(entry.name, entry.type)}
|
matchingTasks={getMatchingTasks(entry.name, entry.type)}
|
||||||
onRunTask={onRunTask}
|
onRunTask={handleRunTask}
|
||||||
onCreateWorkspace={onCreateWorkspace}
|
onCreateWorkspace={handleCreateWorkspace}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
+7
-7
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useCallback, useEffect, useMemo, useRef } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { Folder, Trash2, Pencil, MoreVertical, MessageSquare, Scissors, Copy, Check, Play, Download, LayoutGrid, Volume2, ScanText, FileText, AudioLines, FolderArchive } from 'lucide-react';
|
import { Folder, Trash2, Pencil, MoreVertical, MessageSquare, Scissors, Copy, Check, Play, Download, LayoutGrid, Volume2, ScanText, FileText, AudioLines, FolderArchive } from 'lucide-react';
|
||||||
import { getIcon } from 'material-file-icons';
|
import { getIcon } from 'material-file-icons';
|
||||||
import {
|
import {
|
||||||
@@ -22,9 +22,9 @@ import {
|
|||||||
ContextMenuTrigger,
|
ContextMenuTrigger,
|
||||||
} from '@/components/ui/context-menu';
|
} from '@/components/ui/context-menu';
|
||||||
import { cardStyle } from '@/components/Card';
|
import { cardStyle } from '@/components/Card';
|
||||||
import type { DirEntry } from './useFiles';
|
import type { DirEntry } from '../../../../hooks/useFilesAPI';
|
||||||
import type { TaskSummary } from './useTasks';
|
import type { TaskSummary } from '../../useTasks';
|
||||||
import { getFileType } from 'apps/FileViewer';
|
import { getFileType } from '../../../FileViewer';
|
||||||
|
|
||||||
export type FileItemProps = {
|
export type FileItemProps = {
|
||||||
entry: DirEntry;
|
entry: DirEntry;
|
||||||
@@ -338,7 +338,7 @@ const InlineRenameInput = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const [value, setValue] = useState(initialName);
|
const [value, setValue] = useState(initialName);
|
||||||
|
|
||||||
const mountRef = useCallback((node: HTMLInputElement | null) => {
|
const mountRef = (node: HTMLInputElement | null) => {
|
||||||
if (!node) return;
|
if (!node) return;
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
node.focus();
|
node.focus();
|
||||||
@@ -349,7 +349,7 @@ const InlineRenameInput = ({
|
|||||||
node.select();
|
node.select();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
const commit = () => {
|
const commit = () => {
|
||||||
const trimmed = value.trim();
|
const trimmed = value.trim();
|
||||||
@@ -399,7 +399,7 @@ const Checkbox = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const MaterialFileIcon = ({ name, className }: { name: string; className?: string }) => {
|
const MaterialFileIcon = ({ name, className }: { name: string; className?: string }) => {
|
||||||
const svg = useMemo(() => getIcon(name).svg, [name]);
|
const svg = getIcon(name).svg;
|
||||||
return <span className={className} dangerouslySetInnerHTML={{ __html: svg }} />;
|
return <span className={className} dangerouslySetInnerHTML={{ __html: svg }} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
+126
@@ -0,0 +1,126 @@
|
|||||||
|
import { Loader2, Folder, ClipboardPaste, FolderPlus, LayoutGrid, Download, Upload } from 'lucide-react';
|
||||||
|
import { getIcon } from 'material-file-icons';
|
||||||
|
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu';
|
||||||
|
import type { UseFileBrowserAppType } from '../useFileBrowserApp';
|
||||||
|
import { FileGrid } from './FileGrid';
|
||||||
|
|
||||||
|
type FileViewContainerProps = {
|
||||||
|
fileBrowserManager: UseFileBrowserAppType;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FileViewContainer = ({ fileBrowserManager }: FileViewContainerProps) => {
|
||||||
|
const {
|
||||||
|
searchQuery,
|
||||||
|
searching,
|
||||||
|
searchResults,
|
||||||
|
handleSearchResultClick,
|
||||||
|
dragging,
|
||||||
|
handleDragEnter,
|
||||||
|
handleDragLeave,
|
||||||
|
handleDragOver,
|
||||||
|
handleDrop,
|
||||||
|
fileScrollRef,
|
||||||
|
handleBackgroundClick,
|
||||||
|
loading,
|
||||||
|
handlePaste,
|
||||||
|
handleCreateDir,
|
||||||
|
handleCreateWorkspaceHere,
|
||||||
|
setShowVideoDownload,
|
||||||
|
} = fileBrowserManager;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex-1 min-h-0 relative"
|
||||||
|
onDragEnter={handleDragEnter}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
>
|
||||||
|
{dragging && (
|
||||||
|
<div className="absolute inset-0 z-50 flex items-center justify-center bg-duck-teal/10 border-2 border-dashed border-duck-teal rounded-lg m-2 pointer-events-none">
|
||||||
|
<div className="flex flex-col items-center gap-2 text-duck-teal">
|
||||||
|
<Upload className="h-8 w-8" />
|
||||||
|
<span className="text-sm font-medium">Drop files to upload</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{searchQuery.trim() ? (
|
||||||
|
<div className="h-full overflow-auto p-4">
|
||||||
|
{searching ? (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<Loader2 className="h-6 w-6 text-duck-teal animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : searchResults && searchResults.length > 0 ? (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
{searchResults.map((entry) => {
|
||||||
|
const isDir = entry.type === 'directory';
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={entry.path}
|
||||||
|
className="flex items-center gap-3 px-3 py-2 hover:bg-duck-teal/5 cursor-pointer border-b border-duck-dark/5 last:border-b-0"
|
||||||
|
onClick={() => handleSearchResultClick(entry)}
|
||||||
|
>
|
||||||
|
{isDir ? (
|
||||||
|
<Folder className="h-5 w-5 shrink-0 text-duck-yellow fill-duck-yellow/30" />
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className="inline-flex h-5 w-5 shrink-0"
|
||||||
|
dangerouslySetInnerHTML={{ __html: getIcon(entry.name).svg }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<span className="text-sm font-medium text-duck-dark block truncate">{entry.name}</span>
|
||||||
|
<span className="text-xs text-duck-dark/40 block truncate">{entry.path}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : searchResults ? (
|
||||||
|
<div className="flex items-center justify-center py-20 text-duck-dark/40 text-sm">
|
||||||
|
No results found
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ContextMenu>
|
||||||
|
<ContextMenuTrigger asChild>
|
||||||
|
<div ref={fileScrollRef} className="h-full overflow-auto p-4" onClick={handleBackgroundClick}>
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<Loader2 className="h-6 w-6 text-duck-teal animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<FileGrid fileBrowserManager={fileBrowserManager} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ContextMenuTrigger>
|
||||||
|
<ContextMenuContent className="z-[600]">
|
||||||
|
<ContextMenuItem onClick={handlePaste} className="cursor-pointer">
|
||||||
|
<ClipboardPaste className="mr-2 h-4 w-4" />
|
||||||
|
Paste
|
||||||
|
</ContextMenuItem>
|
||||||
|
<ContextMenuItem
|
||||||
|
onClick={() => {
|
||||||
|
const name = prompt('Folder name');
|
||||||
|
if (name?.trim()) handleCreateDir(name.trim());
|
||||||
|
}}
|
||||||
|
className="cursor-pointer"
|
||||||
|
>
|
||||||
|
<FolderPlus className="mr-2 h-4 w-4" />
|
||||||
|
New folder
|
||||||
|
</ContextMenuItem>
|
||||||
|
<ContextMenuItem onClick={handleCreateWorkspaceHere} className="cursor-pointer">
|
||||||
|
<LayoutGrid className="mr-2 h-4 w-4" />
|
||||||
|
Create Workspace here
|
||||||
|
</ContextMenuItem>
|
||||||
|
<ContextMenuItem onClick={() => setShowVideoDownload(true)} className="cursor-pointer">
|
||||||
|
<Download className="mr-2 h-4 w-4" />
|
||||||
|
Download video
|
||||||
|
</ContextMenuItem>
|
||||||
|
</ContextMenuContent>
|
||||||
|
</ContextMenu>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
+42
@@ -0,0 +1,42 @@
|
|||||||
|
import { Home, FolderRoot, Code } from 'lucide-react';
|
||||||
|
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||||
|
import type { UseFileBrowserAppType } from '../useFileBrowserApp';
|
||||||
|
|
||||||
|
type HomeDirSelectorProps = {
|
||||||
|
fileBrowserManager: UseFileBrowserAppType;
|
||||||
|
basePath: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const HomeDirSelector = ({ fileBrowserManager, basePath }: HomeDirSelectorProps) => {
|
||||||
|
const { user, homeRoot, setHomeRoot, currentPath, setCurrentPath } = fileBrowserManager;
|
||||||
|
|
||||||
|
if (basePath !== '/' || user?.role !== 'Super Admin') return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="shrink-0 border-b border-duck-dark/10 px-4 py-2">
|
||||||
|
<RadioGroup
|
||||||
|
value={homeRoot}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
setHomeRoot(v as 'home' | '~' | 'officer.dev');
|
||||||
|
if (currentPath !== '/') setCurrentPath('/');
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-4"
|
||||||
|
>
|
||||||
|
<label className="flex items-center gap-1.5 cursor-pointer text-sm text-duck-dark/70">
|
||||||
|
<RadioGroupItem value="home" />
|
||||||
|
<Home className="h-3.5 w-3.5" />
|
||||||
|
Home dir
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-1.5 cursor-pointer text-sm text-duck-dark/70">
|
||||||
|
<RadioGroupItem value="~" />
|
||||||
|
<FolderRoot className="h-3.5 w-3.5" />~
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-1.5 cursor-pointer text-sm text-duck-dark/70">
|
||||||
|
<RadioGroupItem value="officer.dev" />
|
||||||
|
<Code className="h-3.5 w-3.5" />
|
||||||
|
officer.dev
|
||||||
|
</label>
|
||||||
|
</RadioGroup>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
+28
@@ -0,0 +1,28 @@
|
|||||||
|
import type { UseFileBrowserAppType } from '../useFileBrowserApp';
|
||||||
|
import { TaskRunnerModal } from './TaskRunnerModal';
|
||||||
|
|
||||||
|
type TaskRunnerDialogProps = {
|
||||||
|
fileBrowserManager: UseFileBrowserAppType;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TaskRunnerDialog = ({ fileBrowserManager }: TaskRunnerDialogProps) => {
|
||||||
|
const { runningTask, setRunningTask, refresh, homeRoot, currentPath } = fileBrowserManager;
|
||||||
|
|
||||||
|
if (!runningTask) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TaskRunnerModal
|
||||||
|
open
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
setRunningTask(null);
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
task={runningTask.task}
|
||||||
|
entryName={runningTask.entry.name}
|
||||||
|
entryType={runningTask.entry.type}
|
||||||
|
cwd={{ root: homeRoot, path: currentPath.replace(/^\//, '') }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
+4
-7
@@ -3,11 +3,10 @@ import { X } from 'lucide-react';
|
|||||||
import { Dialog, DialogOverlay, DialogPortal } from '@/components/ui/dialog';
|
import { Dialog, DialogOverlay, DialogPortal } from '@/components/ui/dialog';
|
||||||
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||||
import { cardStyle } from '@/components/Card';
|
import { cardStyle } from '@/components/Card';
|
||||||
import type { TaskInfo } from 'apps/Chat';
|
import type { TaskInfo } from '../../../Chat';
|
||||||
import { usePi, EmbeddableChat } from 'apps/Chat';
|
import { usePiChat, EmbeddableChat } from '../../../Chat';
|
||||||
import { useVisiblePiModels } from '@/state/useModels';
|
|
||||||
import { useSettings } from '@/state/useSettings';
|
import { useSettings } from '@/state/useSettings';
|
||||||
import type { TaskSummary } from './useTasks';
|
import type { TaskSummary } from '../../useTasks';
|
||||||
|
|
||||||
const playDing = () => {
|
const playDing = () => {
|
||||||
const ctx = new AudioContext();
|
const ctx = new AudioContext();
|
||||||
@@ -46,8 +45,7 @@ const PiMonoInner = ({
|
|||||||
initialModel,
|
initialModel,
|
||||||
taskInfo,
|
taskInfo,
|
||||||
}: PiMonoInnerProps) => {
|
}: PiMonoInnerProps) => {
|
||||||
const chat = usePi(undefined, initialModel, { replaceUrl: false, taskInfo });
|
const chat = usePiChat(undefined, initialModel, { replaceUrl: false, taskInfo });
|
||||||
const models = useVisiblePiModels();
|
|
||||||
|
|
||||||
const wasGenerating = useRef(false);
|
const wasGenerating = useRef(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -58,7 +56,6 @@ const PiMonoInner = ({
|
|||||||
return (
|
return (
|
||||||
<EmbeddableChat
|
<EmbeddableChat
|
||||||
chat={chat}
|
chat={chat}
|
||||||
availableModels={models}
|
|
||||||
defaultInput={defaultInput}
|
defaultInput={defaultInput}
|
||||||
cwd={cwd}
|
cwd={cwd}
|
||||||
className="flex-1 min-h-0"
|
className="flex-1 min-h-0"
|
||||||
+29
@@ -0,0 +1,29 @@
|
|||||||
|
import { RefreshCw } from 'lucide-react';
|
||||||
|
import type { UseFileBrowserAppType } from '../../useFileBrowserApp';
|
||||||
|
import { SelectionActions } from './SelectionActions';
|
||||||
|
import { DefaultActions } from './DefaultActions';
|
||||||
|
|
||||||
|
type ActionButtonsProps = {
|
||||||
|
fileBrowserManager: UseFileBrowserAppType;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ActionButtons = ({ fileBrowserManager }: ActionButtonsProps) => {
|
||||||
|
const { refresh, selected } = fileBrowserManager;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={refresh}
|
||||||
|
title="Refresh"
|
||||||
|
className="p-1.5 rounded-md text-duck-dark/50 hover:bg-duck-dark/5 cursor-pointer transition-colors"
|
||||||
|
>
|
||||||
|
<RefreshCw className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
{selected.size > 0 ? (
|
||||||
|
<SelectionActions fileBrowserManager={fileBrowserManager} />
|
||||||
|
) : (
|
||||||
|
<DefaultActions fileBrowserManager={fileBrowserManager} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
+71
@@ -0,0 +1,71 @@
|
|||||||
|
import { useRef } from 'react';
|
||||||
|
import { FolderPlus, Upload, FolderUp, ClipboardPaste } from 'lucide-react';
|
||||||
|
import type { UseFileBrowserAppType } from '../../useFileBrowserApp';
|
||||||
|
|
||||||
|
type DefaultActionsProps = {
|
||||||
|
fileBrowserManager: UseFileBrowserAppType;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DefaultActions = ({ fileBrowserManager }: DefaultActionsProps) => {
|
||||||
|
const { handleCreateDir, handleUpload, clipboard, handlePaste } = fileBrowserManager;
|
||||||
|
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
const folderInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
|
const handleFileChange = (ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
if (ev.target.files?.length) {
|
||||||
|
handleUpload(ev.target.files);
|
||||||
|
ev.target.value = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const name = prompt('Folder name');
|
||||||
|
if (name?.trim()) handleCreateDir(name.trim());
|
||||||
|
}}
|
||||||
|
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" />
|
||||||
|
<input ref={fileInputRef} type="file" multiple className="hidden" onChange={handleFileChange} />
|
||||||
|
</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" />
|
||||||
|
<input
|
||||||
|
ref={(input) => {
|
||||||
|
folderInputRef.current = input;
|
||||||
|
if (input) input.setAttribute('webkitdirectory', '');
|
||||||
|
}}
|
||||||
|
type="file"
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleFileChange}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{clipboard !== null && (
|
||||||
|
<button
|
||||||
|
onClick={handlePaste}
|
||||||
|
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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
+64
@@ -0,0 +1,64 @@
|
|||||||
|
import { Scissors, Copy, ClipboardPaste, Download, Trash2, X } from 'lucide-react';
|
||||||
|
import type { UseFileBrowserAppType } from '../../useFileBrowserApp';
|
||||||
|
|
||||||
|
type SelectionActionsProps = {
|
||||||
|
fileBrowserManager: UseFileBrowserAppType;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SelectionActions = ({ fileBrowserManager }: SelectionActionsProps) => {
|
||||||
|
const { selected, clipboard, handleCut, handleCopy, handlePaste, handleDownloadSelected, handleDeleteSelected, setSelected } =
|
||||||
|
fileBrowserManager;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<span className="text-sm font-medium text-duck-dark/70 mr-1">
|
||||||
|
{selected.size}
|
||||||
|
<span className="hidden md:inline"> selected</span>
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={handleCut}
|
||||||
|
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={handleCopy}
|
||||||
|
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>
|
||||||
|
{clipboard !== null && (
|
||||||
|
<button
|
||||||
|
onClick={handlePaste}
|
||||||
|
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={handleDownloadSelected}
|
||||||
|
title="Download"
|
||||||
|
className="p-1.5 rounded-md text-duck-dark/50 hover:bg-duck-dark/5 cursor-pointer transition-colors"
|
||||||
|
>
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleDeleteSelected}
|
||||||
|
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={() => setSelected(new Set())}
|
||||||
|
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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
+129
@@ -0,0 +1,129 @@
|
|||||||
|
import { Loader2, LayoutGrid, List, Search, X, Check, GitBranch, Eye, EyeOff } from 'lucide-react';
|
||||||
|
import type { UseFileBrowserAppType } from '../../useFileBrowserApp';
|
||||||
|
import { ActionButtons } from './ActionButtons';
|
||||||
|
|
||||||
|
type ToolbarProps = {
|
||||||
|
fileBrowserManager: UseFileBrowserAppType;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Toolbar = ({ fileBrowserManager }: ToolbarProps) => {
|
||||||
|
const {
|
||||||
|
showCloneInput,
|
||||||
|
setShowCloneInput,
|
||||||
|
cloneUrl,
|
||||||
|
setCloneUrl,
|
||||||
|
cloning,
|
||||||
|
handleGitClone,
|
||||||
|
searchQuery,
|
||||||
|
setSearchQuery,
|
||||||
|
searchInputRef,
|
||||||
|
showHidden,
|
||||||
|
setShowHidden,
|
||||||
|
viewMode,
|
||||||
|
setViewMode,
|
||||||
|
} = fileBrowserManager;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="shrink-0 flex items-center gap-2 md:gap-3 px-3 md:px-4 h-12 md:h-14 border-b border-duck-dark/10 overflow-hidden">
|
||||||
|
<ActionButtons fileBrowserManager={fileBrowserManager} />
|
||||||
|
<div className="flex-1" />
|
||||||
|
{showCloneInput ? (
|
||||||
|
<form
|
||||||
|
onSubmit={(ev) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
handleGitClone();
|
||||||
|
}}
|
||||||
|
className="hidden md:flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
value={cloneUrl}
|
||||||
|
onChange={(ev) => setCloneUrl(ev.target.value)}
|
||||||
|
placeholder="https://github.com/user/repo.git"
|
||||||
|
className="h-8 w-40 md:w-64 text-sm rounded-md border border-duck-dark/20 bg-background/60 text-duck-dark placeholder:text-duck-dark/40 outline-none focus:border-duck-teal/50 px-2"
|
||||||
|
disabled={cloning}
|
||||||
|
onKeyDown={(ev) => {
|
||||||
|
if (ev.key === 'Escape') {
|
||||||
|
setShowCloneInput(false);
|
||||||
|
setCloneUrl('');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={cloning}
|
||||||
|
className="p-1.5 rounded-md text-duck-teal hover:bg-duck-dark/5 cursor-pointer transition-colors disabled:opacity-40"
|
||||||
|
title="Clone"
|
||||||
|
>
|
||||||
|
{cloning ? <Loader2 className="h-4 w-4 animate-spin" /> : <Check className="h-4 w-4" />}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={cloning}
|
||||||
|
onClick={() => {
|
||||||
|
setShowCloneInput(false);
|
||||||
|
setCloneUrl('');
|
||||||
|
}}
|
||||||
|
className="p-1.5 rounded-md text-duck-dark/50 hover:bg-duck-dark/5 cursor-pointer transition-colors disabled:opacity-40"
|
||||||
|
title="Cancel"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCloneInput(true)}
|
||||||
|
title="Git clone"
|
||||||
|
className="hidden md:block p-1.5 rounded-md text-duck-dark/50 hover:bg-duck-dark/5 cursor-pointer transition-colors"
|
||||||
|
>
|
||||||
|
<GitBranch className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<div className="relative flex items-center">
|
||||||
|
<Search className="absolute left-2 h-4 w-4 text-duck-dark/40 pointer-events-none" />
|
||||||
|
<input
|
||||||
|
ref={searchInputRef}
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(ev) => setSearchQuery(ev.target.value)}
|
||||||
|
onKeyDown={(ev) => {
|
||||||
|
if (ev.key === 'Escape') {
|
||||||
|
setSearchQuery('');
|
||||||
|
searchInputRef.current?.blur();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="Search files..."
|
||||||
|
className="h-8 w-28 focus:w-40 md:w-40 md:focus:w-56 transition-all pl-8 pr-7 text-base md:text-sm rounded-md border border-duck-dark/20 bg-background/60 text-duck-dark placeholder:text-duck-dark/40 outline-none focus:border-duck-teal/50"
|
||||||
|
/>
|
||||||
|
{searchQuery && (
|
||||||
|
<button
|
||||||
|
onClick={() => setSearchQuery('')}
|
||||||
|
className="absolute right-1.5 p-0.5 rounded text-duck-dark/40 hover:text-duck-dark/70 cursor-pointer"
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowHidden((v) => !v)}
|
||||||
|
className={`hidden md:block p-1.5 rounded-md cursor-pointer transition-colors ${showHidden ? 'bg-duck-teal text-duck-yellow' : 'text-duck-dark/50 hover:bg-duck-dark/5'}`}
|
||||||
|
title={showHidden ? 'Hide hidden files' : 'Show hidden files'}
|
||||||
|
>
|
||||||
|
{showHidden ? <Eye className="h-4 w-4" /> : <EyeOff className="h-4 w-4" />}
|
||||||
|
</button>
|
||||||
|
<div className="flex items-center border border-duck-dark/20 rounded-md overflow-hidden">
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode('grid')}
|
||||||
|
className={`p-1.5 cursor-pointer transition-colors ${viewMode === 'grid' ? 'bg-duck-teal text-duck-yellow' : 'text-duck-dark/50 hover:bg-duck-dark/5'}`}
|
||||||
|
>
|
||||||
|
<LayoutGrid className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode('list')}
|
||||||
|
className={`p-1.5 cursor-pointer transition-colors ${viewMode === 'list' ? 'bg-duck-teal text-duck-yellow' : 'text-duck-dark/50 hover:bg-duck-dark/5'}`}
|
||||||
|
>
|
||||||
|
<List className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
export * from './Toolbar';
|
||||||
+26
@@ -0,0 +1,26 @@
|
|||||||
|
import type { UseFileBrowserAppType } from '../useFileBrowserApp';
|
||||||
|
|
||||||
|
type UploadProgressProps = {
|
||||||
|
fileBrowserManager: UseFileBrowserAppType;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const UploadProgress = ({ fileBrowserManager }: UploadProgressProps) => {
|
||||||
|
const { uploadProgress } = fileBrowserManager;
|
||||||
|
|
||||||
|
if (uploadProgress === null) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="shrink-0 px-4 pt-3">
|
||||||
|
<div className="flex items-center justify-between text-sm text-duck-dark/70 mb-1">
|
||||||
|
<span>Uploading...</span>
|
||||||
|
<span>{uploadProgress}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 rounded-full bg-duck-dark/10 overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-duck-teal transition-all duration-300"
|
||||||
|
style={{ width: `${uploadProgress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
+69
@@ -0,0 +1,69 @@
|
|||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import type { UseFileBrowserAppType } from '../useFileBrowserApp';
|
||||||
|
|
||||||
|
type VideoDownloadDialogProps = {
|
||||||
|
fileBrowserManager: UseFileBrowserAppType;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const VideoDownloadDialog = ({ fileBrowserManager }: VideoDownloadDialogProps) => {
|
||||||
|
const { showVideoDownload, setShowVideoDownload, videoUrl, setVideoUrl, audioOnly, setAudioOnly, handleVideoDownload } =
|
||||||
|
fileBrowserManager;
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setShowVideoDownload(false);
|
||||||
|
setVideoUrl('');
|
||||||
|
setAudioOnly(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={showVideoDownload}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) handleClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Download video</DialogTitle>
|
||||||
|
<DialogDescription>Download a video from a URL using yt-dlp</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<form
|
||||||
|
onSubmit={(ev) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
handleVideoDownload();
|
||||||
|
}}
|
||||||
|
className="flex flex-col gap-4"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
value={videoUrl}
|
||||||
|
onChange={(ev) => setVideoUrl(ev.target.value)}
|
||||||
|
placeholder="https://www.youtube.com/watch?v=..."
|
||||||
|
className="h-10 w-full text-sm rounded-md border border-duck-dark/20 bg-background/60 text-duck-dark placeholder:text-duck-dark/40 outline-none focus:border-duck-teal/50 px-3"
|
||||||
|
/>
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer text-sm text-duck-dark/70">
|
||||||
|
<Checkbox checked={audioOnly} onCheckedChange={(v) => setAudioOnly(v === true)} />
|
||||||
|
Extract audio only (mp3)
|
||||||
|
</label>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleClose}
|
||||||
|
className="px-4 py-2 text-sm rounded-md text-duck-dark/70 hover:bg-duck-dark/5 cursor-pointer transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!videoUrl.trim()}
|
||||||
|
className="px-4 py-2 text-sm rounded-md bg-duck-teal text-duck-yellow hover:bg-duck-teal/90 cursor-pointer transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
Download
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export { Breadcrumb } from './Breadcrumb';
|
||||||
|
export { Toolbar } from './Toolbar';
|
||||||
|
export { FileGrid } from './FileGrid';
|
||||||
|
export { FileItem, type FileItemProps } from './FileItem';
|
||||||
|
export { TaskRunnerModal } from './TaskRunnerModal';
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './FileBrowserApp';
|
||||||
+211
-546
@@ -1,51 +1,13 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { useSearchParams, useNavigate } from 'react-router';
|
import { useSearchParams, useNavigate } from 'react-router';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import {
|
import { useFilesAPI, type DirEntry } from '../../../hooks/useFilesAPI';
|
||||||
Loader2,
|
import { useTasks, type TaskSummary } from '../useTasks';
|
||||||
LayoutGrid,
|
|
||||||
List,
|
|
||||||
ClipboardPaste,
|
|
||||||
FolderPlus,
|
|
||||||
Search,
|
|
||||||
X,
|
|
||||||
Check,
|
|
||||||
Folder,
|
|
||||||
GitBranch,
|
|
||||||
Home,
|
|
||||||
FolderRoot,
|
|
||||||
Code,
|
|
||||||
Eye,
|
|
||||||
EyeOff,
|
|
||||||
Upload,
|
|
||||||
Download,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { getIcon } from 'material-file-icons';
|
|
||||||
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu';
|
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
|
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
|
||||||
import { useFiles, type DirEntry } from './useFiles';
|
|
||||||
import { useTasks, type TaskSummary } from './useTasks';
|
|
||||||
import { Breadcrumb } from './Breadcrumb';
|
|
||||||
import { Toolbar } from './Toolbar';
|
|
||||||
import { useClient } from 'hooks/useClient';
|
|
||||||
import { useUserState } from '@/state/useUserState';
|
import { useUserState } from '@/state/useUserState';
|
||||||
import { useAuth } from 'hooks/useAuth';
|
import { useAuth } from 'hooks/useAuth';
|
||||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
|
||||||
import { FileGrid } from './FileGrid';
|
|
||||||
import { TaskRunnerModal } from './TaskRunnerModal';
|
|
||||||
|
|
||||||
type ClipboardState = { paths: string[]; mode: 'copy' | 'cut' } | null;
|
export const useFileBrowserApp = (basePath: string) => {
|
||||||
|
|
||||||
type HomeRoot = 'home' | '~' | 'officer.dev';
|
|
||||||
|
|
||||||
type FilesProps = {
|
|
||||||
basePath?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Files = ({ basePath = '/' }: FilesProps) => {
|
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const client = useClient();
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const [homeRoot, setHomeRoot] = useUserState<HomeRoot>('files/homeRoot', 'home');
|
const [homeRoot, setHomeRoot] = useUserState<HomeRoot>('files/homeRoot', 'home');
|
||||||
@@ -53,7 +15,6 @@ export const Files = ({ basePath = '/' }: FilesProps) => {
|
|||||||
const [entries, setEntries] = useState<DirEntry[]>([]);
|
const [entries, setEntries] = useState<DirEntry[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [viewMode, setViewMode] = useUserState<'grid' | 'list'>('files/viewMode', 'grid');
|
const [viewMode, setViewMode] = useUserState<'grid' | 'list'>('files/viewMode', 'grid');
|
||||||
|
|
||||||
const [showHidden, setShowHidden] = useUserState<boolean>('files/showHidden', false);
|
const [showHidden, setShowHidden] = useUserState<boolean>('files/showHidden', false);
|
||||||
const [uploadProgress, setUploadProgress] = useState<number | null>(null);
|
const [uploadProgress, setUploadProgress] = useState<number | null>(null);
|
||||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||||
@@ -76,14 +37,17 @@ export const Files = ({ basePath = '/' }: FilesProps) => {
|
|||||||
const searchInputRef = useRef<HTMLInputElement | null>(null);
|
const searchInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
const fileScrollRef = useRef<HTMLDivElement | null>(null);
|
const fileScrollRef = useRef<HTMLDivElement | null>(null);
|
||||||
const viewPath = searchParams.get('view');
|
const viewPath = searchParams.get('view');
|
||||||
const files = useFiles(homeRoot);
|
const files = useFilesAPI(homeRoot);
|
||||||
const filesRef = useRef(files);
|
const filesRef = useRef(files);
|
||||||
filesRef.current = files;
|
filesRef.current = files;
|
||||||
const currentPathRef = useRef(currentPath);
|
const currentPathRef = useRef(currentPath);
|
||||||
currentPathRef.current = currentPath;
|
currentPathRef.current = currentPath;
|
||||||
const visibleEntries = showHidden ? entries : entries.filter((e) => !e.name.startsWith('.'));
|
const visibleEntries = showHidden ? entries : entries.filter((e) => !e.name.startsWith('.'));
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
const entryPath = (name: string) => (currentPath === '/' ? `/${name}` : `${currentPath}/${name}`);
|
||||||
|
const selectedPaths = () => Array.from(selected).map(entryPath);
|
||||||
|
|
||||||
|
const refresh = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const data = await filesRef.current.listDir(currentPathRef.current);
|
const data = await filesRef.current.listDir(currentPathRef.current);
|
||||||
@@ -98,7 +62,7 @@ export const Files = ({ basePath = '/' }: FilesProps) => {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (basePath !== '/' && !currentPath.startsWith(basePath)) {
|
if (basePath !== '/' && !currentPath.startsWith(basePath)) {
|
||||||
@@ -108,7 +72,6 @@ export const Files = ({ basePath = '/' }: FilesProps) => {
|
|||||||
refresh();
|
refresh();
|
||||||
}, [currentPath, homeRoot]);
|
}, [currentPath, homeRoot]);
|
||||||
|
|
||||||
// Clear selection when navigating
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSelected(new Set());
|
setSelected(new Set());
|
||||||
}, [currentPath]);
|
}, [currentPath]);
|
||||||
@@ -139,6 +102,121 @@ export const Files = ({ basePath = '/' }: FilesProps) => {
|
|||||||
};
|
};
|
||||||
}, [searchQuery]);
|
}, [searchQuery]);
|
||||||
|
|
||||||
|
// Clipboard image paste
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (ev: ClipboardEvent) => {
|
||||||
|
const tag = (ev.target as HTMLElement).tagName;
|
||||||
|
if (tag === 'INPUT' || tag === 'TEXTAREA') return;
|
||||||
|
|
||||||
|
const items = ev.clipboardData?.items;
|
||||||
|
if (!items) return;
|
||||||
|
|
||||||
|
const imageFiles: File[] = [];
|
||||||
|
for (let i = 0; i < items.length; i++) {
|
||||||
|
const item = items[i]!;
|
||||||
|
if (item.type.startsWith('image/')) {
|
||||||
|
const file = item.getAsFile();
|
||||||
|
if (file) imageFiles.push(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (imageFiles.length === 0) return;
|
||||||
|
|
||||||
|
ev.preventDefault();
|
||||||
|
const now = new Date();
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0');
|
||||||
|
const ts = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
|
||||||
|
|
||||||
|
const dt = new DataTransfer();
|
||||||
|
imageFiles.forEach((file, i) => {
|
||||||
|
const ext = file.type.split('/')[1] ?? 'png';
|
||||||
|
const name = imageFiles.length === 1 ? `clipboard-${ts}.${ext}` : `clipboard-${ts}-${i + 1}.${ext}`;
|
||||||
|
dt.items.add(new File([file], name, { type: file.type }));
|
||||||
|
});
|
||||||
|
handleUpload(dt.files);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('paste', handler);
|
||||||
|
return () => window.removeEventListener('paste', handler);
|
||||||
|
}, [currentPath, homeRoot]);
|
||||||
|
|
||||||
|
// Keyboard shortcuts
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (ev: KeyboardEvent) => {
|
||||||
|
const tag = (ev.target as HTMLElement).tagName;
|
||||||
|
if (tag === 'INPUT' || tag === 'TEXTAREA') return;
|
||||||
|
|
||||||
|
if (ev.key === 'Escape') {
|
||||||
|
if (viewPath) return;
|
||||||
|
if (searchQuery) {
|
||||||
|
setSearchQuery('');
|
||||||
|
searchInputRef.current?.blur();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSelected(new Set());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((ev.ctrlKey || ev.metaKey) && ev.key === 'a') {
|
||||||
|
ev.preventDefault();
|
||||||
|
setSelected(new Set(visibleEntries.map((e) => e.name)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((ev.ctrlKey || ev.metaKey) && ev.key === 'c') {
|
||||||
|
ev.preventDefault();
|
||||||
|
if (selected.size > 0) {
|
||||||
|
const paths = selectedPaths();
|
||||||
|
setClipboard({ paths, mode: 'copy' });
|
||||||
|
toast.success(`Copied ${paths.length} item${paths.length !== 1 ? 's' : ''}`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((ev.ctrlKey || ev.metaKey) && ev.key === 'x') {
|
||||||
|
ev.preventDefault();
|
||||||
|
if (selected.size > 0) {
|
||||||
|
const paths = selectedPaths();
|
||||||
|
setClipboard({ paths, mode: 'cut' });
|
||||||
|
toast.success(`Cut ${paths.length} item${paths.length !== 1 ? 's' : ''}`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((ev.ctrlKey || ev.metaKey) && ev.key === 'v') {
|
||||||
|
if (clipboard) {
|
||||||
|
ev.preventDefault();
|
||||||
|
handlePaste();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ev.key === 'Delete' || ev.key === 'Backspace') {
|
||||||
|
if (selected.size > 0) {
|
||||||
|
ev.preventDefault();
|
||||||
|
handleDeleteSelected();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ev.key === 'F2') {
|
||||||
|
if (selected.size === 1) {
|
||||||
|
ev.preventDefault();
|
||||||
|
setRenamingName(Array.from(selected)[0]!);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ev.key.length === 1 && !ev.ctrlKey && !ev.metaKey && !ev.altKey) {
|
||||||
|
searchInputRef.current?.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handler);
|
||||||
|
return () => window.removeEventListener('keydown', handler);
|
||||||
|
}, [visibleEntries, selected, clipboard, currentPath, viewPath]);
|
||||||
|
|
||||||
|
// ── Handlers ──
|
||||||
|
|
||||||
const handleSearchResultClick = (entry: DirEntry) => {
|
const handleSearchResultClick = (entry: DirEntry) => {
|
||||||
if (!entry.path) return;
|
if (!entry.path) return;
|
||||||
if (entry.type === 'directory') {
|
if (entry.type === 'directory') {
|
||||||
@@ -151,10 +229,6 @@ export const Files = ({ basePath = '/' }: FilesProps) => {
|
|||||||
setSearchQuery('');
|
setSearchQuery('');
|
||||||
};
|
};
|
||||||
|
|
||||||
const entryPath = (name: string) => (currentPath === '/' ? `/${name}` : `${currentPath}/${name}`);
|
|
||||||
|
|
||||||
const selectedPaths = () => Array.from(selected).map(entryPath);
|
|
||||||
|
|
||||||
const handleNavigate = (path: string) => {
|
const handleNavigate = (path: string) => {
|
||||||
if (basePath !== '/' && !path.startsWith(basePath)) {
|
if (basePath !== '/' && !path.startsWith(basePath)) {
|
||||||
setCurrentPath(basePath);
|
setCurrentPath(basePath);
|
||||||
@@ -211,7 +285,6 @@ export const Files = ({ basePath = '/' }: FilesProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (entry: DirEntry) => {
|
const handleDelete = async (entry: DirEntry) => {
|
||||||
// If the entry is part of a multi-selection, delete all selected
|
|
||||||
if (selected.has(entry.name) && selected.size > 1) {
|
if (selected.has(entry.name) && selected.size > 1) {
|
||||||
handleDeleteSelected();
|
handleDeleteSelected();
|
||||||
return;
|
return;
|
||||||
@@ -301,7 +374,7 @@ export const Files = ({ basePath = '/' }: FilesProps) => {
|
|||||||
const filePath = entryPath(entry.name);
|
const filePath = entryPath(entry.name);
|
||||||
const toastId = toast.loading('Generating speech audio...');
|
const toastId = toast.loading('Generating speech audio...');
|
||||||
try {
|
try {
|
||||||
const { audioPath, audioRoot } = await client.post<{ audioPath: string; audioRoot: string }>('/file-browser/tts', { path: filePath, root: homeRoot });
|
const { audioPath, audioRoot } = await files.tts(filePath);
|
||||||
toast.dismiss(toastId);
|
toast.dismiss(toastId);
|
||||||
setSearchParams({ view: filePath, ephemeral: audioPath, ephemeralRoot: audioRoot });
|
setSearchParams({ view: filePath, ephemeral: audioPath, ephemeralRoot: audioRoot });
|
||||||
} catch {
|
} catch {
|
||||||
@@ -313,7 +386,7 @@ export const Files = ({ basePath = '/' }: FilesProps) => {
|
|||||||
const filePath = entryPath(entry.name);
|
const filePath = entryPath(entry.name);
|
||||||
const toastId = toast.loading('Extracting text from image...');
|
const toastId = toast.loading('Extracting text from image...');
|
||||||
try {
|
try {
|
||||||
const { ocrPath, ocrRoot } = await client.post<{ ocrPath: string; ocrRoot: string }>('/file-browser/ocr', { path: filePath, root: homeRoot });
|
const { ocrPath, ocrRoot } = await files.ocr(filePath);
|
||||||
toast.dismiss(toastId);
|
toast.dismiss(toastId);
|
||||||
setSearchParams({ view: filePath, ephemeral: ocrPath, ephemeralRoot: ocrRoot });
|
setSearchParams({ view: filePath, ephemeral: ocrPath, ephemeralRoot: ocrRoot });
|
||||||
} catch {
|
} catch {
|
||||||
@@ -325,7 +398,7 @@ export const Files = ({ basePath = '/' }: FilesProps) => {
|
|||||||
const filePath = entryPath(entry.name);
|
const filePath = entryPath(entry.name);
|
||||||
const toastId = toast.loading('Transcribing audio...');
|
const toastId = toast.loading('Transcribing audio...');
|
||||||
try {
|
try {
|
||||||
const { transcriptionPath, transcriptionRoot } = await client.post<{ transcriptionPath: string; transcriptionRoot: string }>('/file-browser/transcribe', { path: filePath, root: homeRoot });
|
const { transcriptionPath, transcriptionRoot } = await files.transcribe(filePath);
|
||||||
toast.dismiss(toastId);
|
toast.dismiss(toastId);
|
||||||
setSearchParams({ view: filePath, ephemeral: transcriptionPath, ephemeralRoot: transcriptionRoot });
|
setSearchParams({ view: filePath, ephemeral: transcriptionPath, ephemeralRoot: transcriptionRoot });
|
||||||
} catch {
|
} catch {
|
||||||
@@ -337,7 +410,7 @@ export const Files = ({ basePath = '/' }: FilesProps) => {
|
|||||||
const filePath = entryPath(entry.name);
|
const filePath = entryPath(entry.name);
|
||||||
const toastId = toast.loading('Extracting audio from video...');
|
const toastId = toast.loading('Extracting audio from video...');
|
||||||
try {
|
try {
|
||||||
const { audioPath, audioRoot } = await client.post<{ audioPath: string; audioRoot: string }>('/file-browser/extract-audio', { path: filePath, root: homeRoot });
|
const { audioPath, audioRoot } = await files.extractAudio(filePath);
|
||||||
toast.dismiss(toastId);
|
toast.dismiss(toastId);
|
||||||
setSearchParams({ view: filePath, ephemeral: audioPath, ephemeralRoot: audioRoot });
|
setSearchParams({ view: filePath, ephemeral: audioPath, ephemeralRoot: audioRoot });
|
||||||
} catch {
|
} catch {
|
||||||
@@ -349,7 +422,7 @@ export const Files = ({ basePath = '/' }: FilesProps) => {
|
|||||||
const filePath = entryPath(entry.name);
|
const filePath = entryPath(entry.name);
|
||||||
const toastId = toast.loading('Extracting archive...');
|
const toastId = toast.loading('Extracting archive...');
|
||||||
try {
|
try {
|
||||||
const { extractedPath } = await client.post<{ extractedPath: string }>('/file-browser/extract', { path: filePath, root: homeRoot });
|
const { extractedPath } = await files.extract(filePath);
|
||||||
const folderName = extractedPath.split('/').pop() ?? extractedPath;
|
const folderName = extractedPath.split('/').pop() ?? extractedPath;
|
||||||
toast.success(`Extracted to "${folderName}"`, { id: toastId });
|
toast.success(`Extracted to "${folderName}"`, { id: toastId });
|
||||||
await refresh();
|
await refresh();
|
||||||
@@ -406,7 +479,6 @@ export const Files = ({ basePath = '/' }: FilesProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handlePaste = async () => {
|
const handlePaste = async () => {
|
||||||
// Internal file clipboard takes priority
|
|
||||||
if (clipboard) {
|
if (clipboard) {
|
||||||
try {
|
try {
|
||||||
if (clipboard.mode === 'copy') {
|
if (clipboard.mode === 'copy') {
|
||||||
@@ -423,7 +495,6 @@ export const Files = ({ basePath = '/' }: FilesProps) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try system clipboard for images/files
|
|
||||||
try {
|
try {
|
||||||
const clipboardItems = await navigator.clipboard.read();
|
const clipboardItems = await navigator.clipboard.read();
|
||||||
const imageFiles: File[] = [];
|
const imageFiles: File[] = [];
|
||||||
@@ -445,125 +516,10 @@ export const Files = ({ basePath = '/' }: FilesProps) => {
|
|||||||
await handleUpload(dt.files);
|
await handleUpload(dt.files);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Clipboard API not available or permission denied — silently ignore
|
// Clipboard API not available or permission denied
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Clipboard image paste
|
|
||||||
useEffect(() => {
|
|
||||||
const handler = (ev: ClipboardEvent) => {
|
|
||||||
const tag = (ev.target as HTMLElement).tagName;
|
|
||||||
if (tag === 'INPUT' || tag === 'TEXTAREA') return;
|
|
||||||
|
|
||||||
const items = ev.clipboardData?.items;
|
|
||||||
if (!items) return;
|
|
||||||
|
|
||||||
const imageFiles: File[] = [];
|
|
||||||
for (let i = 0; i < items.length; i++) {
|
|
||||||
const item = items[i]!;
|
|
||||||
if (item.type.startsWith('image/')) {
|
|
||||||
const file = item.getAsFile();
|
|
||||||
if (file) imageFiles.push(file);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (imageFiles.length === 0) return;
|
|
||||||
|
|
||||||
ev.preventDefault();
|
|
||||||
const now = new Date();
|
|
||||||
const pad = (n: number) => String(n).padStart(2, '0');
|
|
||||||
const ts = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
|
|
||||||
|
|
||||||
const dt = new DataTransfer();
|
|
||||||
imageFiles.forEach((file, i) => {
|
|
||||||
const ext = file.type.split('/')[1] ?? 'png';
|
|
||||||
const name = imageFiles.length === 1 ? `clipboard-${ts}.${ext}` : `clipboard-${ts}-${i + 1}.${ext}`;
|
|
||||||
dt.items.add(new File([file], name, { type: file.type }));
|
|
||||||
});
|
|
||||||
handleUpload(dt.files);
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener('paste', handler);
|
|
||||||
return () => window.removeEventListener('paste', handler);
|
|
||||||
}, [currentPath, homeRoot]);
|
|
||||||
|
|
||||||
// Keyboard shortcuts
|
|
||||||
useEffect(() => {
|
|
||||||
const handler = (ev: KeyboardEvent) => {
|
|
||||||
// Don't capture when typing in inputs
|
|
||||||
const tag = (ev.target as HTMLElement).tagName;
|
|
||||||
if (tag === 'INPUT' || tag === 'TEXTAREA') return;
|
|
||||||
|
|
||||||
if (ev.key === 'Escape') {
|
|
||||||
if (viewPath) return; // Let the viewer handle it
|
|
||||||
if (searchQuery) {
|
|
||||||
setSearchQuery('');
|
|
||||||
searchInputRef.current?.blur();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSelected(new Set());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((ev.ctrlKey || ev.metaKey) && ev.key === 'a') {
|
|
||||||
ev.preventDefault();
|
|
||||||
setSelected(new Set(visibleEntries.map((e) => e.name)));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((ev.ctrlKey || ev.metaKey) && ev.key === 'c') {
|
|
||||||
ev.preventDefault();
|
|
||||||
if (selected.size > 0) {
|
|
||||||
const paths = selectedPaths();
|
|
||||||
setClipboard({ paths, mode: 'copy' });
|
|
||||||
toast.success(`Copied ${paths.length} item${paths.length !== 1 ? 's' : ''}`);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((ev.ctrlKey || ev.metaKey) && ev.key === 'x') {
|
|
||||||
ev.preventDefault();
|
|
||||||
if (selected.size > 0) {
|
|
||||||
const paths = selectedPaths();
|
|
||||||
setClipboard({ paths, mode: 'cut' });
|
|
||||||
toast.success(`Cut ${paths.length} item${paths.length !== 1 ? 's' : ''}`);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((ev.ctrlKey || ev.metaKey) && ev.key === 'v') {
|
|
||||||
if (clipboard) {
|
|
||||||
ev.preventDefault();
|
|
||||||
handlePaste();
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ev.key === 'Delete' || ev.key === 'Backspace') {
|
|
||||||
if (selected.size > 0) {
|
|
||||||
ev.preventDefault();
|
|
||||||
handleDeleteSelected();
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ev.key === 'F2') {
|
|
||||||
if (selected.size === 1) {
|
|
||||||
ev.preventDefault();
|
|
||||||
setRenamingName(Array.from(selected)[0]!);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Type-to-search: focus search bar on printable character
|
|
||||||
if (ev.key.length === 1 && !ev.ctrlKey && !ev.metaKey && !ev.altKey) {
|
|
||||||
searchInputRef.current?.focus();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener('keydown', handler);
|
|
||||||
return () => window.removeEventListener('keydown', handler);
|
|
||||||
}, [visibleEntries, selected, clipboard, currentPath, viewPath]);
|
|
||||||
|
|
||||||
const handleDragEnter = (ev: React.DragEvent) => {
|
const handleDragEnter = (ev: React.DragEvent) => {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
dragCounter.current++;
|
dragCounter.current++;
|
||||||
@@ -592,22 +548,19 @@ export const Files = ({ basePath = '/' }: FilesProps) => {
|
|||||||
const items = ev.dataTransfer.items;
|
const items = ev.dataTransfer.items;
|
||||||
if (!items || items.length === 0) return;
|
if (!items || items.length === 0) return;
|
||||||
|
|
||||||
// Collect FileSystemEntry objects (supports folders)
|
const fsEntries: FileSystemEntry[] = [];
|
||||||
const entries: FileSystemEntry[] = [];
|
|
||||||
for (let i = 0; i < items.length; i++) {
|
for (let i = 0; i < items.length; i++) {
|
||||||
const entry = items[i]!.webkitGetAsEntry?.();
|
const entry = items[i]!.webkitGetAsEntry?.();
|
||||||
if (entry) entries.push(entry);
|
if (entry) fsEntries.push(entry);
|
||||||
}
|
}
|
||||||
|
|
||||||
// If no entries (browser doesn't support webkitGetAsEntry), fall back to files
|
if (fsEntries.length === 0) {
|
||||||
if (entries.length === 0) {
|
|
||||||
if (ev.dataTransfer.files.length > 0) {
|
if (ev.dataTransfer.files.length > 0) {
|
||||||
handleUpload(ev.dataTransfer.files);
|
handleUpload(ev.dataTransfer.files);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Recursively read all files from entries
|
|
||||||
const allFiles: File[] = [];
|
const allFiles: File[] = [];
|
||||||
|
|
||||||
const readEntry = (entry: FileSystemEntry, path: string): Promise<void> => {
|
const readEntry = (entry: FileSystemEntry, path: string): Promise<void> => {
|
||||||
@@ -631,7 +584,7 @@ export const Files = ({ basePath = '/' }: FilesProps) => {
|
|||||||
for (const child of batch) {
|
for (const child of batch) {
|
||||||
await readEntry(child, path + entry.name + '/');
|
await readEntry(child, path + entry.name + '/');
|
||||||
}
|
}
|
||||||
readBatch(); // readEntries may not return all at once
|
readBatch();
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
readBatch();
|
readBatch();
|
||||||
@@ -640,7 +593,7 @@ export const Files = ({ basePath = '/' }: FilesProps) => {
|
|||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
};
|
};
|
||||||
|
|
||||||
for (const entry of entries) {
|
for (const entry of fsEntries) {
|
||||||
await readEntry(entry, '');
|
await readEntry(entry, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -654,367 +607,79 @@ export const Files = ({ basePath = '/' }: FilesProps) => {
|
|||||||
setSelected(new Set());
|
setSelected(new Set());
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return {
|
||||||
<>
|
// Auth
|
||||||
<div className="flex flex-col h-full overflow-hidden">
|
user,
|
||||||
{/* Toolbar */}
|
// Navigation
|
||||||
<div className="shrink-0 flex items-center gap-2 md:gap-3 px-3 md:px-4 h-12 md:h-14 border-b border-duck-dark/10 overflow-hidden">
|
homeRoot, setHomeRoot,
|
||||||
<Toolbar
|
currentPath, setCurrentPath,
|
||||||
onRefresh={refresh}
|
// Directory listing
|
||||||
onCreateDir={handleCreateDir}
|
visibleEntries, loading, refresh,
|
||||||
onUpload={handleUpload}
|
// View
|
||||||
selectionCount={selected.size}
|
viewMode, setViewMode,
|
||||||
hasClipboard={clipboard !== null}
|
showHidden, setShowHidden,
|
||||||
onCut={handleCut}
|
// Selection
|
||||||
onCopy={handleCopy}
|
selected, setSelected,
|
||||||
onPaste={handlePaste}
|
clipboard,
|
||||||
onDownloadSelected={handleDownloadSelected}
|
renamingName, setRenamingName,
|
||||||
onDeleteSelected={handleDeleteSelected}
|
// Search
|
||||||
onClearSelection={() => setSelected(new Set())}
|
searchQuery, setSearchQuery,
|
||||||
/>
|
searchResults, searching,
|
||||||
<div className="flex-1" />
|
searchInputRef,
|
||||||
{showCloneInput ? (
|
// Upload
|
||||||
<form
|
uploadProgress,
|
||||||
onSubmit={(ev) => {
|
// Clone
|
||||||
ev.preventDefault();
|
showCloneInput, setShowCloneInput,
|
||||||
handleGitClone();
|
cloneUrl, setCloneUrl,
|
||||||
}}
|
cloning,
|
||||||
className="hidden md:flex items-center gap-2"
|
// Drag & drop
|
||||||
>
|
dragging,
|
||||||
<input
|
// Task runner
|
||||||
autoFocus
|
runningTask, setRunningTask,
|
||||||
value={cloneUrl}
|
getMatchingTasks,
|
||||||
onChange={(ev) => setCloneUrl(ev.target.value)}
|
// Video download
|
||||||
placeholder="https://github.com/user/repo.git"
|
showVideoDownload, setShowVideoDownload,
|
||||||
className="h-8 w-40 md:w-64 text-sm rounded-md border border-duck-dark/20 bg-background/60 text-duck-dark placeholder:text-duck-dark/40 outline-none focus:border-duck-teal/50 px-2"
|
videoUrl, setVideoUrl,
|
||||||
disabled={cloning}
|
audioOnly, setAudioOnly,
|
||||||
onKeyDown={(ev) => {
|
// Refs
|
||||||
if (ev.key === 'Escape') {
|
fileScrollRef,
|
||||||
setShowCloneInput(false);
|
// Handlers
|
||||||
setCloneUrl('');
|
handleSearchResultClick,
|
||||||
}
|
handleNavigate,
|
||||||
}}
|
handleOpen,
|
||||||
/>
|
handleCreateDir,
|
||||||
<button
|
handleUpload,
|
||||||
type="submit"
|
handleRename,
|
||||||
disabled={cloning}
|
handleDelete,
|
||||||
className="p-1.5 rounded-md text-duck-teal hover:bg-duck-dark/5 cursor-pointer transition-colors disabled:opacity-40"
|
handleDeleteSelected,
|
||||||
title="Clone"
|
handleChat,
|
||||||
>
|
handleDownload,
|
||||||
{cloning ? <Loader2 className="h-4 w-4 animate-spin" /> : <Check className="h-4 w-4" />}
|
handleDownloadSelected,
|
||||||
</button>
|
handleRunTask,
|
||||||
<button
|
handleCreateWorkspace,
|
||||||
type="button"
|
handleCreateWorkspaceHere,
|
||||||
disabled={cloning}
|
handleReadAloud,
|
||||||
onClick={() => {
|
handleOcr,
|
||||||
setShowCloneInput(false);
|
handleTranscribe,
|
||||||
setCloneUrl('');
|
handleExtractAudio,
|
||||||
}}
|
handleExtract,
|
||||||
className="p-1.5 rounded-md text-duck-dark/50 hover:bg-duck-dark/5 cursor-pointer transition-colors disabled:opacity-40"
|
handleGitClone,
|
||||||
title="Cancel"
|
handleVideoDownload,
|
||||||
>
|
handleCut,
|
||||||
<X className="h-4 w-4" />
|
handleCopy,
|
||||||
</button>
|
handlePaste,
|
||||||
</form>
|
handleDragEnter,
|
||||||
) : (
|
handleDragLeave,
|
||||||
<button
|
handleDragOver,
|
||||||
onClick={() => setShowCloneInput(true)}
|
handleDrop,
|
||||||
title="Git clone"
|
handleBackgroundClick,
|
||||||
className="hidden md:block p-1.5 rounded-md text-duck-dark/50 hover:bg-duck-dark/5 cursor-pointer transition-colors"
|
};
|
||||||
>
|
|
||||||
<GitBranch className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<div className="relative flex items-center">
|
|
||||||
<Search className="absolute left-2 h-4 w-4 text-duck-dark/40 pointer-events-none" />
|
|
||||||
<input
|
|
||||||
ref={searchInputRef}
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(ev) => setSearchQuery(ev.target.value)}
|
|
||||||
onKeyDown={(ev) => {
|
|
||||||
if (ev.key === 'Escape') {
|
|
||||||
setSearchQuery('');
|
|
||||||
searchInputRef.current?.blur();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
placeholder="Search files..."
|
|
||||||
className="h-8 w-28 focus:w-40 md:w-40 md:focus:w-56 transition-all pl-8 pr-7 text-base md:text-sm rounded-md border border-duck-dark/20 bg-background/60 text-duck-dark placeholder:text-duck-dark/40 outline-none focus:border-duck-teal/50"
|
|
||||||
/>
|
|
||||||
{searchQuery && (
|
|
||||||
<button
|
|
||||||
onClick={() => setSearchQuery('')}
|
|
||||||
className="absolute right-1.5 p-0.5 rounded text-duck-dark/40 hover:text-duck-dark/70 cursor-pointer"
|
|
||||||
>
|
|
||||||
<X className="h-3.5 w-3.5" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => setShowHidden((v) => !v)}
|
|
||||||
className={`hidden md:block p-1.5 rounded-md cursor-pointer transition-colors ${showHidden ? 'bg-duck-teal text-duck-yellow' : 'text-duck-dark/50 hover:bg-duck-dark/5'}`}
|
|
||||||
title={showHidden ? 'Hide hidden files' : 'Show hidden files'}
|
|
||||||
>
|
|
||||||
{showHidden ? <Eye className="h-4 w-4" /> : <EyeOff className="h-4 w-4" />}
|
|
||||||
</button>
|
|
||||||
<div className="flex items-center border border-duck-dark/20 rounded-md overflow-hidden">
|
|
||||||
<button
|
|
||||||
onClick={() => setViewMode('grid')}
|
|
||||||
className={`p-1.5 cursor-pointer transition-colors ${viewMode === 'grid' ? 'bg-duck-teal text-duck-yellow' : 'text-duck-dark/50 hover:bg-duck-dark/5'}`}
|
|
||||||
>
|
|
||||||
<LayoutGrid className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setViewMode('list')}
|
|
||||||
className={`p-1.5 cursor-pointer transition-colors ${viewMode === 'list' ? 'bg-duck-teal text-duck-yellow' : 'text-duck-dark/50 hover:bg-duck-dark/5'}`}
|
|
||||||
>
|
|
||||||
<List className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Home dir selector (Super Admin only) */}
|
|
||||||
{basePath === '/' && user?.role === 'Super Admin' && (
|
|
||||||
<div className="shrink-0 border-b border-duck-dark/10 px-4 py-2">
|
|
||||||
<RadioGroup
|
|
||||||
value={homeRoot}
|
|
||||||
onValueChange={(v) => {
|
|
||||||
setHomeRoot(v as HomeRoot);
|
|
||||||
if (currentPath !== '/') setCurrentPath('/');
|
|
||||||
}}
|
|
||||||
className="flex items-center gap-4"
|
|
||||||
>
|
|
||||||
<label className="flex items-center gap-1.5 cursor-pointer text-sm text-duck-dark/70">
|
|
||||||
<RadioGroupItem value="home" />
|
|
||||||
<Home className="h-3.5 w-3.5" />
|
|
||||||
Home dir
|
|
||||||
</label>
|
|
||||||
<label className="flex items-center gap-1.5 cursor-pointer text-sm text-duck-dark/70">
|
|
||||||
<RadioGroupItem value="~" />
|
|
||||||
<FolderRoot className="h-3.5 w-3.5" />~
|
|
||||||
</label>
|
|
||||||
<label className="flex items-center gap-1.5 cursor-pointer text-sm text-duck-dark/70">
|
|
||||||
<RadioGroupItem value="officer.dev" />
|
|
||||||
<Code className="h-3.5 w-3.5" />
|
|
||||||
officer.dev
|
|
||||||
</label>
|
|
||||||
</RadioGroup>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Breadcrumb */}
|
|
||||||
<div className="shrink-0 border-b border-duck-dark/10 px-4 py-2">
|
|
||||||
<Breadcrumb path={currentPath} onNavigate={handleNavigate} basePath={basePath} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Upload progress */}
|
|
||||||
{uploadProgress !== null && (
|
|
||||||
<div className="shrink-0 px-4 pt-3">
|
|
||||||
<div className="flex items-center justify-between text-sm text-duck-dark/70 mb-1">
|
|
||||||
<span>Uploading...</span>
|
|
||||||
<span>{uploadProgress}%</span>
|
|
||||||
</div>
|
|
||||||
<div className="h-2 rounded-full bg-duck-dark/10 overflow-hidden">
|
|
||||||
<div
|
|
||||||
className="h-full rounded-full bg-duck-teal transition-all duration-300"
|
|
||||||
style={{ width: `${uploadProgress}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* File content */}
|
|
||||||
<div
|
|
||||||
className="flex-1 min-h-0 relative"
|
|
||||||
onDragEnter={handleDragEnter}
|
|
||||||
onDragLeave={handleDragLeave}
|
|
||||||
onDragOver={handleDragOver}
|
|
||||||
onDrop={handleDrop}
|
|
||||||
>
|
|
||||||
{dragging && (
|
|
||||||
<div className="absolute inset-0 z-50 flex items-center justify-center bg-duck-teal/10 border-2 border-dashed border-duck-teal rounded-lg m-2 pointer-events-none">
|
|
||||||
<div className="flex flex-col items-center gap-2 text-duck-teal">
|
|
||||||
<Upload className="h-8 w-8" />
|
|
||||||
<span className="text-sm font-medium">Drop files to upload</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{searchQuery.trim() ? (
|
|
||||||
<div className="h-full overflow-auto p-4">
|
|
||||||
{searching ? (
|
|
||||||
<div className="flex items-center justify-center h-full">
|
|
||||||
<Loader2 className="h-6 w-6 text-duck-teal animate-spin" />
|
|
||||||
</div>
|
|
||||||
) : searchResults && searchResults.length > 0 ? (
|
|
||||||
<div className="flex flex-col">
|
|
||||||
{searchResults.map((entry) => {
|
|
||||||
const isDir = entry.type === 'directory';
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={entry.path}
|
|
||||||
className="flex items-center gap-3 px-3 py-2 hover:bg-duck-teal/5 cursor-pointer border-b border-duck-dark/5 last:border-b-0"
|
|
||||||
onClick={() => handleSearchResultClick(entry)}
|
|
||||||
>
|
|
||||||
{isDir ? (
|
|
||||||
<Folder className="h-5 w-5 shrink-0 text-duck-yellow fill-duck-yellow/30" />
|
|
||||||
) : (
|
|
||||||
<span
|
|
||||||
className="inline-flex h-5 w-5 shrink-0"
|
|
||||||
dangerouslySetInnerHTML={{ __html: getIcon(entry.name).svg }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<span className="text-sm font-medium text-duck-dark block truncate">{entry.name}</span>
|
|
||||||
<span className="text-xs text-duck-dark/40 block truncate">{entry.path}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
) : searchResults ? (
|
|
||||||
<div className="flex items-center justify-center py-20 text-duck-dark/40 text-sm">
|
|
||||||
No results found
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<ContextMenu>
|
|
||||||
<ContextMenuTrigger asChild>
|
|
||||||
<div ref={fileScrollRef} className="h-full overflow-auto p-4" onClick={handleBackgroundClick}>
|
|
||||||
{loading ? (
|
|
||||||
<div className="flex items-center justify-center h-full">
|
|
||||||
<Loader2 className="h-6 w-6 text-duck-teal animate-spin" />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<FileGrid
|
|
||||||
entries={visibleEntries}
|
|
||||||
viewMode={viewMode}
|
|
||||||
currentPath={currentPath}
|
|
||||||
selected={selected}
|
|
||||||
clipboard={clipboard}
|
|
||||||
onOpen={handleOpen}
|
|
||||||
onDelete={handleDelete}
|
|
||||||
onRename={handleRename}
|
|
||||||
onChat={handleChat}
|
|
||||||
onDownload={handleDownload}
|
|
||||||
onSelect={setSelected}
|
|
||||||
onCut={handleCut}
|
|
||||||
onCopy={handleCopy}
|
|
||||||
renamingName={renamingName}
|
|
||||||
onRenamingChange={setRenamingName}
|
|
||||||
onReadAloud={handleReadAloud}
|
|
||||||
onOcr={handleOcr}
|
|
||||||
onTranscribe={handleTranscribe}
|
|
||||||
onExtractAudio={handleExtractAudio}
|
|
||||||
onExtract={handleExtract}
|
|
||||||
getMatchingTasks={getMatchingTasks}
|
|
||||||
onRunTask={handleRunTask}
|
|
||||||
onCreateWorkspace={handleCreateWorkspace}
|
|
||||||
scrollRef={fileScrollRef}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</ContextMenuTrigger>
|
|
||||||
<ContextMenuContent className="z-[600]">
|
|
||||||
<ContextMenuItem onClick={handlePaste} className="cursor-pointer">
|
|
||||||
<ClipboardPaste className="mr-2 h-4 w-4" />
|
|
||||||
Paste
|
|
||||||
</ContextMenuItem>
|
|
||||||
<ContextMenuItem
|
|
||||||
onClick={() => {
|
|
||||||
const name = prompt('Folder name');
|
|
||||||
if (name?.trim()) handleCreateDir(name.trim());
|
|
||||||
}}
|
|
||||||
className="cursor-pointer"
|
|
||||||
>
|
|
||||||
<FolderPlus className="mr-2 h-4 w-4" />
|
|
||||||
New folder
|
|
||||||
</ContextMenuItem>
|
|
||||||
<ContextMenuItem onClick={handleCreateWorkspaceHere} className="cursor-pointer">
|
|
||||||
<LayoutGrid className="mr-2 h-4 w-4" />
|
|
||||||
Create Workspace here
|
|
||||||
</ContextMenuItem>
|
|
||||||
<ContextMenuItem onClick={() => setShowVideoDownload(true)} className="cursor-pointer">
|
|
||||||
<Download className="mr-2 h-4 w-4" />
|
|
||||||
Download video
|
|
||||||
</ContextMenuItem>
|
|
||||||
</ContextMenuContent>
|
|
||||||
</ContextMenu>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{runningTask && (
|
|
||||||
<TaskRunnerModal
|
|
||||||
open
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
if (!open) {
|
|
||||||
setRunningTask(null);
|
|
||||||
refresh();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
task={runningTask.task}
|
|
||||||
entryName={runningTask.entry.name}
|
|
||||||
entryType={runningTask.entry.type}
|
|
||||||
cwd={{ root: homeRoot, path: currentPath.replace(/^\//, '') }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Dialog
|
|
||||||
open={showVideoDownload}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
if (!open) {
|
|
||||||
setShowVideoDownload(false);
|
|
||||||
setVideoUrl('');
|
|
||||||
setAudioOnly(false);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Download video</DialogTitle>
|
|
||||||
<DialogDescription>Download a video from a URL using yt-dlp</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<form
|
|
||||||
onSubmit={(ev) => {
|
|
||||||
ev.preventDefault();
|
|
||||||
handleVideoDownload();
|
|
||||||
}}
|
|
||||||
className="flex flex-col gap-4"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
autoFocus
|
|
||||||
value={videoUrl}
|
|
||||||
onChange={(ev) => setVideoUrl(ev.target.value)}
|
|
||||||
placeholder="https://www.youtube.com/watch?v=..."
|
|
||||||
className="h-10 w-full text-sm rounded-md border border-duck-dark/20 bg-background/60 text-duck-dark placeholder:text-duck-dark/40 outline-none focus:border-duck-teal/50 px-3"
|
|
||||||
/>
|
|
||||||
<label className="flex items-center gap-2 cursor-pointer text-sm text-duck-dark/70">
|
|
||||||
<Checkbox checked={audioOnly} onCheckedChange={(v) => setAudioOnly(v === true)} />
|
|
||||||
Extract audio only (mp3)
|
|
||||||
</label>
|
|
||||||
<div className="flex justify-end gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setShowVideoDownload(false);
|
|
||||||
setVideoUrl('');
|
|
||||||
setAudioOnly(false);
|
|
||||||
}}
|
|
||||||
className="px-4 py-2 text-sm rounded-md text-duck-dark/70 hover:bg-duck-dark/5 cursor-pointer transition-colors"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={!videoUrl.trim()}
|
|
||||||
className="px-4 py-2 text-sm rounded-md bg-duck-teal text-duck-yellow hover:bg-duck-teal/90 cursor-pointer transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
Download
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type UseFileBrowserAppType = ReturnType<typeof useFileBrowserApp>;
|
||||||
|
|
||||||
|
// ── Types ──
|
||||||
|
|
||||||
|
type ClipboardState = { paths: string[]; mode: 'copy' | 'cut' } | null;
|
||||||
|
|
||||||
|
type HomeRoot = 'home' | '~' | 'officer.dev';
|
||||||
+29
@@ -0,0 +1,29 @@
|
|||||||
|
import { Widget } from 'widgets/Widget';
|
||||||
|
import { useFileBrowserWidget } from './useFileBrowserWidget';
|
||||||
|
import { Header } from './components/Header';
|
||||||
|
import { SearchResults } from './components/SearchResults';
|
||||||
|
import { BrowseTab } from './components/BrowseTab';
|
||||||
|
import { RecentTab } from './components/RecentTab';
|
||||||
|
import { PinnedTab } from './components/PinnedTab';
|
||||||
|
|
||||||
|
export const FileBrowserWidget = () => {
|
||||||
|
const fileBrowserManager = useFileBrowserWidget();
|
||||||
|
const { tab, isSearching } = fileBrowserManager;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Widget title="File Browser">
|
||||||
|
<Header fileBrowserManager={fileBrowserManager} />
|
||||||
|
<div className="px-4 pb-3 max-h-72 overflow-y-auto">
|
||||||
|
{isSearching ? (
|
||||||
|
<SearchResults fileBrowserManager={fileBrowserManager} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{tab === 'browse' && <BrowseTab fileBrowserManager={fileBrowserManager} />}
|
||||||
|
{tab === 'recent' && <RecentTab fileBrowserManager={fileBrowserManager} />}
|
||||||
|
{tab === 'pinned' && <PinnedTab fileBrowserManager={fileBrowserManager} />}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Widget>
|
||||||
|
);
|
||||||
|
};
|
||||||
+46
@@ -0,0 +1,46 @@
|
|||||||
|
import { Loader2 } from 'lucide-react';
|
||||||
|
import { Breadcrumb } from '../../FileBrowserApp/components/Breadcrumb';
|
||||||
|
import type { UseFileBrowserWidgetType } from '../useFileBrowserWidget';
|
||||||
|
import { EntryRow } from './EntryRow';
|
||||||
|
|
||||||
|
type BrowseTabProps = {
|
||||||
|
fileBrowserManager: UseFileBrowserWidgetType;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const BrowseTab = ({ fileBrowserManager }: BrowseTabProps) => {
|
||||||
|
const { browsePath, setBrowsePath, entries, loading, openFile, isPinned, togglePin } = fileBrowserManager;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="py-2">
|
||||||
|
<Breadcrumb path={browsePath} onNavigate={setBrowsePath} />
|
||||||
|
</div>
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-6 text-duck-dark/40">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : entries.length === 0 ? (
|
||||||
|
<p className="text-xs text-duck-dark/40 py-4 text-center">Empty directory</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-0.5">
|
||||||
|
{entries.map((entry) => {
|
||||||
|
const fullPath = browsePath === '/' ? `/${entry.name}` : `${browsePath}/${entry.name}`;
|
||||||
|
return (
|
||||||
|
<EntryRow
|
||||||
|
key={entry.name}
|
||||||
|
name={entry.name}
|
||||||
|
type={entry.type}
|
||||||
|
pinned={entry.type === 'file' && isPinned(fullPath)}
|
||||||
|
onPin={entry.type === 'file' ? () => togglePin(fullPath, entry.name) : undefined}
|
||||||
|
onClick={() => {
|
||||||
|
if (entry.type === 'directory') setBrowsePath(fullPath);
|
||||||
|
else openFile(fullPath, entry.name);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
+47
@@ -0,0 +1,47 @@
|
|||||||
|
import { Folder, Pin, PinOff } from 'lucide-react';
|
||||||
|
import { getIcon } from 'material-file-icons';
|
||||||
|
|
||||||
|
type EntryRowProps = {
|
||||||
|
name: string;
|
||||||
|
subtitle?: string;
|
||||||
|
type: 'file' | 'directory';
|
||||||
|
pinned?: boolean;
|
||||||
|
onPin?: () => void;
|
||||||
|
onClick: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MaterialFileIcon = ({ name, className }: { name: string; className?: string }) => {
|
||||||
|
const svg = getIcon(name).svg;
|
||||||
|
return <span className={className} dangerouslySetInnerHTML={{ __html: svg }} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EntryRow = ({ name, subtitle, type, pinned, onPin, onClick }: EntryRowProps) => (
|
||||||
|
<li className="flex items-center gap-2 px-2 py-1.5 rounded-md hover:bg-duck-dark/5 group">
|
||||||
|
<button onClick={onClick} className="flex items-center gap-2 flex-1 min-w-0 text-left cursor-pointer">
|
||||||
|
{type === 'directory' ? (
|
||||||
|
<Folder className="h-4 w-4 shrink-0 text-duck-teal" />
|
||||||
|
) : (
|
||||||
|
<MaterialFileIcon name={name} className="inline-flex h-4 w-4 shrink-0" />
|
||||||
|
)}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<span className="text-sm text-duck-dark truncate block">{name}</span>
|
||||||
|
{subtitle && <span className="text-xs text-duck-dark/40 truncate block">{subtitle}</span>}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
{onPin && (
|
||||||
|
<button
|
||||||
|
onClick={(ev) => {
|
||||||
|
ev.stopPropagation();
|
||||||
|
onPin();
|
||||||
|
}}
|
||||||
|
className={`shrink-0 p-1 rounded transition-colors cursor-pointer ${
|
||||||
|
pinned
|
||||||
|
? 'text-duck-teal hover:text-duck-teal/70'
|
||||||
|
: 'text-duck-dark/20 opacity-0 group-hover:opacity-100 hover:text-duck-dark/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{pinned ? <PinOff className="h-3.5 w-3.5" /> : <Pin className="h-3.5 w-3.5" />}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
+55
@@ -0,0 +1,55 @@
|
|||||||
|
import { FolderOpen, Clock, Pin, Search, X } from 'lucide-react';
|
||||||
|
import type { Tab, UseFileBrowserWidgetType } from '../useFileBrowserWidget';
|
||||||
|
|
||||||
|
const TABS: { key: Tab; label: string; icon: typeof FolderOpen }[] = [
|
||||||
|
{ key: 'browse', label: 'Browse', icon: FolderOpen },
|
||||||
|
{ key: 'recent', label: 'Recent', icon: Clock },
|
||||||
|
{ key: 'pinned', label: 'Pinned', icon: Pin },
|
||||||
|
];
|
||||||
|
|
||||||
|
type HeaderProps = {
|
||||||
|
fileBrowserManager: UseFileBrowserWidgetType;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Header = ({ fileBrowserManager }: HeaderProps) => {
|
||||||
|
const { tab, setTab, isSearching, searchQuery, setSearchQuery } = fileBrowserManager;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 px-4 pb-1">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{TABS.map(({ key, label, icon: Icon }) => (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
onClick={() => setTab(key)}
|
||||||
|
className={`flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-colors cursor-pointer ${
|
||||||
|
!isSearching && tab === key
|
||||||
|
? 'bg-duck-teal/10 text-duck-teal'
|
||||||
|
: 'text-duck-dark/50 hover:text-duck-dark/70 hover:bg-duck-dark/5'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon className="h-3.5 w-3.5" />
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="relative ml-auto w-28 md:w-44">
|
||||||
|
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-duck-dark/30" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(ev) => setSearchQuery(ev.target.value)}
|
||||||
|
placeholder="Search..."
|
||||||
|
className="w-full rounded-lg border border-duck-dark/20 bg-background pl-8 pr-8 py-1 text-xs text-duck-dark placeholder:text-duck-dark/30 focus:outline-none focus:ring-2 focus:ring-duck-teal/30 focus:border-duck-teal/50"
|
||||||
|
/>
|
||||||
|
{searchQuery && (
|
||||||
|
<button
|
||||||
|
onClick={() => setSearchQuery('')}
|
||||||
|
className="absolute right-2 top-1/2 -translate-y-1/2 text-duck-dark/30 hover:text-duck-dark/60 cursor-pointer"
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
+30
@@ -0,0 +1,30 @@
|
|||||||
|
import type { UseFileBrowserWidgetType } from '../useFileBrowserWidget';
|
||||||
|
import { EntryRow } from './EntryRow';
|
||||||
|
|
||||||
|
type PinnedTabProps = {
|
||||||
|
fileBrowserManager: UseFileBrowserWidgetType;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PinnedTab = ({ fileBrowserManager }: PinnedTabProps) => {
|
||||||
|
const { pinned, openFile, togglePin } = fileBrowserManager;
|
||||||
|
|
||||||
|
if (pinned.length === 0) {
|
||||||
|
return <p className="text-xs text-duck-dark/40 py-4 text-center">No pinned files</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul className="space-y-0.5">
|
||||||
|
{pinned.map((f) => (
|
||||||
|
<EntryRow
|
||||||
|
key={f.path}
|
||||||
|
name={f.name}
|
||||||
|
subtitle={f.path}
|
||||||
|
type="file"
|
||||||
|
pinned
|
||||||
|
onPin={() => togglePin(f.path, f.name)}
|
||||||
|
onClick={() => openFile(f.path, f.name)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
};
|
||||||
+30
@@ -0,0 +1,30 @@
|
|||||||
|
import type { UseFileBrowserWidgetType } from '../useFileBrowserWidget';
|
||||||
|
import { EntryRow } from './EntryRow';
|
||||||
|
|
||||||
|
type RecentTabProps = {
|
||||||
|
fileBrowserManager: UseFileBrowserWidgetType;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const RecentTab = ({ fileBrowserManager }: RecentTabProps) => {
|
||||||
|
const { recents, openFile, isPinned, togglePin } = fileBrowserManager;
|
||||||
|
|
||||||
|
if (recents.length === 0) {
|
||||||
|
return <p className="text-xs text-duck-dark/40 py-4 text-center">No recent files</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul className="space-y-0.5">
|
||||||
|
{recents.map((f) => (
|
||||||
|
<EntryRow
|
||||||
|
key={f.path}
|
||||||
|
name={f.name}
|
||||||
|
subtitle={f.path}
|
||||||
|
type="file"
|
||||||
|
pinned={isPinned(f.path)}
|
||||||
|
onPin={() => togglePin(f.path, f.name)}
|
||||||
|
onClick={() => openFile(f.path, f.name)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
};
|
||||||
+41
@@ -0,0 +1,41 @@
|
|||||||
|
import { Loader2 } from 'lucide-react';
|
||||||
|
import type { UseFileBrowserWidgetType } from '../useFileBrowserWidget';
|
||||||
|
import { EntryRow } from './EntryRow';
|
||||||
|
|
||||||
|
type SearchResultsProps = {
|
||||||
|
fileBrowserManager: UseFileBrowserWidgetType;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SearchResults = ({ fileBrowserManager }: SearchResultsProps) => {
|
||||||
|
const { searching, searchResults, openFile, isPinned, togglePin } = fileBrowserManager;
|
||||||
|
|
||||||
|
if (searching) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-6 text-duck-dark/40">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (searchResults.length === 0) {
|
||||||
|
return <p className="text-xs text-duck-dark/40 py-4 text-center">No results</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul className="space-y-0.5">
|
||||||
|
{searchResults.map((entry) => (
|
||||||
|
<EntryRow
|
||||||
|
key={entry.path}
|
||||||
|
name={entry.name}
|
||||||
|
subtitle={entry.path}
|
||||||
|
type={entry.type}
|
||||||
|
pinned={entry.type === 'file' && isPinned(entry.path!)}
|
||||||
|
onPin={entry.type === 'file' ? () => togglePin(entry.path!, entry.name) : undefined}
|
||||||
|
onClick={() => {
|
||||||
|
if (entry.type === 'file') openFile(entry.path!, entry.name);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './FileBrowserWidget';
|
||||||
+83
@@ -0,0 +1,83 @@
|
|||||||
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
import { useNavigate } from 'react-router';
|
||||||
|
import { useFilesAPI, type DirEntry } from '../../../hooks/useFilesAPI';
|
||||||
|
import { useRecentFiles } from '../useRecentFiles';
|
||||||
|
import { usePinnedFiles } from '../usePinnedFiles';
|
||||||
|
|
||||||
|
export type Tab = 'browse' | 'recent' | 'pinned';
|
||||||
|
|
||||||
|
export const useFileBrowserWidget = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { listDir, search } = useFilesAPI();
|
||||||
|
const { recents, addRecent } = useRecentFiles();
|
||||||
|
const { pinned, togglePin, isPinned } = usePinnedFiles();
|
||||||
|
|
||||||
|
const [tab, setTab] = useState<Tab>('browse');
|
||||||
|
const [browsePath, setBrowsePath] = useState('/');
|
||||||
|
const [entries, setEntries] = useState<DirEntry[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [searchResults, setSearchResults] = useState<DirEntry[]>([]);
|
||||||
|
const [searching, setSearching] = useState(false);
|
||||||
|
const debounceRef = useRef<ReturnType<typeof setTimeout>>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(true);
|
||||||
|
listDir(browsePath)
|
||||||
|
.then((res) => setEntries(res.entries))
|
||||||
|
.catch(() => setEntries([]))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [browsePath]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
|
const q = searchQuery.trim();
|
||||||
|
if (!q) {
|
||||||
|
setSearchResults([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
debounceRef.current = setTimeout(() => {
|
||||||
|
setSearching(true);
|
||||||
|
search(q)
|
||||||
|
.then((res) => setSearchResults(res.results))
|
||||||
|
.catch(() => setSearchResults([]))
|
||||||
|
.finally(() => setSearching(false));
|
||||||
|
}, 300);
|
||||||
|
return () => {
|
||||||
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
|
};
|
||||||
|
}, [searchQuery]);
|
||||||
|
|
||||||
|
const openFile = (path: string, name: string) => {
|
||||||
|
addRecent(path, name);
|
||||||
|
navigate(`/files?view=${encodeURIComponent(path)}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isSearching = searchQuery.trim().length > 0;
|
||||||
|
|
||||||
|
const sorted = [...entries].sort((a, b) => {
|
||||||
|
if (a.type !== b.type) return a.type === 'directory' ? -1 : 1;
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
tab,
|
||||||
|
setTab,
|
||||||
|
browsePath,
|
||||||
|
setBrowsePath,
|
||||||
|
entries: sorted,
|
||||||
|
loading,
|
||||||
|
searchQuery,
|
||||||
|
setSearchQuery,
|
||||||
|
searchResults,
|
||||||
|
searching,
|
||||||
|
isSearching,
|
||||||
|
openFile,
|
||||||
|
recents,
|
||||||
|
pinned,
|
||||||
|
togglePin,
|
||||||
|
isPinned,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UseFileBrowserWidgetType = ReturnType<typeof useFileBrowserWidget>;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export { useFilesAPI, type DirEntry } from '../../hooks/useFilesAPI';
|
||||||
|
export { useTasks, type TaskSummary } from './useTasks';
|
||||||
|
export { useRecentFiles } from './useRecentFiles';
|
||||||
|
export { usePinnedFiles } from './usePinnedFiles';
|
||||||
|
export { FileBrowserApp } from './FileBrowserApp';
|
||||||
|
export { FileBrowserWidget } from './FileBrowserWidget';
|
||||||
|
export { TaskRunnerModal } from './FileBrowserApp/components/TaskRunnerModal';
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { useUserState } from '@/state/useUserState';
|
||||||
|
|
||||||
|
type PinnedFile = { path: string; name: string; pinnedAt: number };
|
||||||
|
|
||||||
|
export const usePinnedFiles = () => {
|
||||||
|
const [pinned, setPinned] = useUserState<PinnedFile[]>('pinnedFiles', []);
|
||||||
|
|
||||||
|
const togglePin = (path: string, name: string) => {
|
||||||
|
setPinned((prev) => {
|
||||||
|
const exists = prev.some((f) => f.path === path);
|
||||||
|
if (exists) return prev.filter((f) => f.path !== path);
|
||||||
|
return [{ path, name, pinnedAt: Date.now() }, ...prev];
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const isPinned = (path: string) => pinned.some((f) => f.path === path);
|
||||||
|
|
||||||
|
return { pinned, togglePin, isPinned };
|
||||||
|
};
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { useUserState } from '@/state/useUserState';
|
||||||
|
|
||||||
|
type RecentFile = { path: string; name: string; openedAt: number };
|
||||||
|
|
||||||
|
const MAX_RECENTS = 20;
|
||||||
|
|
||||||
|
export const useRecentFiles = () => {
|
||||||
|
const [recents, setRecents] = useUserState<RecentFile[]>('recentFiles', []);
|
||||||
|
|
||||||
|
const addRecent = (path: string, name: string) => {
|
||||||
|
setRecents((prev) => {
|
||||||
|
const filtered = prev.filter((f) => f.path !== path);
|
||||||
|
return [{ path, name, openedAt: Date.now() }, ...filtered].slice(0, MAX_RECENTS);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return { recents, addRecent };
|
||||||
|
};
|
||||||
+8
-12
@@ -1,4 +1,3 @@
|
|||||||
import { useCallback } from 'react';
|
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { useClient } from 'hooks/useClient';
|
import { useClient } from 'hooks/useClient';
|
||||||
|
|
||||||
@@ -22,17 +21,14 @@ export const useTasks = () => {
|
|||||||
staleTime: 60_000,
|
staleTime: 60_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const getMatchingTasks = useCallback(
|
const getMatchingTasks = (fileName: string, entryType: 'file' | 'directory'): TaskSummary[] => {
|
||||||
(fileName: string, entryType: 'file' | 'directory'): TaskSummary[] => {
|
if (entryType === 'directory') {
|
||||||
if (entryType === 'directory') {
|
return tasks.filter((t) => t.triggers.some((tr) => tr.type === 'directory'));
|
||||||
return tasks.filter((t) => t.triggers.some((tr) => tr.type === 'directory'));
|
}
|
||||||
}
|
const ext = fileName.split('.').pop()?.toLowerCase();
|
||||||
const ext = fileName.split('.').pop()?.toLowerCase();
|
if (!ext) return [];
|
||||||
if (!ext) return [];
|
return tasks.filter((t) => t.triggers.some((tr) => tr.type === 'file' && tr.extensions.includes(ext)));
|
||||||
return tasks.filter((t) => t.triggers.some((tr) => tr.type === 'file' && tr.extensions.includes(ext)));
|
};
|
||||||
},
|
|
||||||
[tasks],
|
|
||||||
);
|
|
||||||
|
|
||||||
return { tasks, getMatchingTasks };
|
return { tasks, getMatchingTasks };
|
||||||
};
|
};
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { useRef } from 'react';
|
||||||
|
import { Loader2, FolderArchive } from 'lucide-react';
|
||||||
|
import { getLang, getRawUrl, getTranscodeUrl, needsTranscode, getArchiveBaseName } from './file-types';
|
||||||
|
import { useFileViewer } from './FileViewerContext';
|
||||||
|
import { PdfRenderer } from './renderers/PdfRenderer';
|
||||||
|
import { ImageRenderer } from './renderers/ImageRenderer';
|
||||||
|
import { VideoRenderer } from './renderers/VideoRenderer';
|
||||||
|
import { AudioRenderer } from './renderers/AudioRenderer';
|
||||||
|
import { CodeRenderer } from './renderers/CodeRenderer';
|
||||||
|
import { MarkdownRenderer } from './renderers/MarkdownRenderer';
|
||||||
|
import { TextRenderer } from './renderers/TextRenderer';
|
||||||
|
import { ScrollToTopButton } from './renderers/ScrollToTopButton';
|
||||||
|
|
||||||
|
export const FileViewerBody = () => {
|
||||||
|
const { filePath, fileName, root, fileType, content, loading, error, autoPlay } = useFileViewer();
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const videoSrc =
|
||||||
|
fileType === 'video' ? (needsTranscode(fileName) ? getTranscodeUrl(filePath, root) : getRawUrl(filePath, root)) : '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={scrollRef} className="h-full overflow-auto">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<Loader2 className="h-6 w-6 text-duck-teal animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<span className="text-sm text-red-500">{error}</span>
|
||||||
|
</div>
|
||||||
|
) : fileType === 'pdf' ? (
|
||||||
|
<PdfRenderer src={getRawUrl(filePath, root)} />
|
||||||
|
) : fileType === 'archive' ? (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full gap-3 text-duck-dark/50">
|
||||||
|
<FolderArchive className="h-10 w-10" />
|
||||||
|
<span className="text-sm">Archive file</span>
|
||||||
|
<span className="text-xs">{getArchiveBaseName(fileName)}</span>
|
||||||
|
</div>
|
||||||
|
) : fileType === 'image' ? (
|
||||||
|
<ImageRenderer src={getRawUrl(filePath, root)} fileName={fileName} />
|
||||||
|
) : fileType === 'video' ? (
|
||||||
|
<VideoRenderer src={videoSrc} fileName={fileName} />
|
||||||
|
) : fileType === 'audio' ? (
|
||||||
|
<AudioRenderer src={getRawUrl(filePath, root)} fileName={fileName} autoPlay={autoPlay} />
|
||||||
|
) : content !== null ? (
|
||||||
|
fileType === 'code' ? (
|
||||||
|
<div>
|
||||||
|
<CodeRenderer content={content} lang={getLang(fileName)} />
|
||||||
|
<ScrollToTopButton scrollContainer={scrollRef} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="px-8 py-6">
|
||||||
|
{fileType === 'markdown' ? (
|
||||||
|
<MarkdownRenderer content={content} scrollContainer={scrollRef} />
|
||||||
|
) : (
|
||||||
|
<TextRenderer content={content} />
|
||||||
|
)}
|
||||||
|
<ScrollToTopButton scrollContainer={scrollRef} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { createContext, useContext } from 'react';
|
||||||
|
import type { FileType } from './file-types';
|
||||||
|
|
||||||
|
export type FileViewerContextValue = {
|
||||||
|
filePath: string;
|
||||||
|
fileName: string;
|
||||||
|
root: string;
|
||||||
|
fileType: FileType;
|
||||||
|
content: string | null;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
directContent: boolean;
|
||||||
|
ttsLoading: boolean;
|
||||||
|
ocrLoading: boolean;
|
||||||
|
transcribeLoading: boolean;
|
||||||
|
extractAudioLoading: boolean;
|
||||||
|
extractLoading: boolean;
|
||||||
|
autoPlay: boolean;
|
||||||
|
handleReadAloud: () => void;
|
||||||
|
handleOcr: () => void;
|
||||||
|
handleTranscribe: () => void;
|
||||||
|
handleExtractAudio: () => void;
|
||||||
|
handleExtract: () => void;
|
||||||
|
handleDownload: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FileViewerContext = createContext<FileViewerContextValue | null>(null);
|
||||||
|
|
||||||
|
export const useFileViewer = () => {
|
||||||
|
const ctx = useContext(FileViewerContext);
|
||||||
|
if (!ctx) throw new Error('useFileViewer must be used within FileViewerProvider');
|
||||||
|
return ctx;
|
||||||
|
};
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user