Workspaces and Terminals
This commit is contained in:
@@ -23,6 +23,7 @@ type FileGridProps = {
|
|||||||
onRenamingChange: (name: string | null) => void;
|
onRenamingChange: (name: string | null) => void;
|
||||||
getMatchingTasks: (fileName: string, entryType: 'file' | 'directory') => TaskSummary[];
|
getMatchingTasks: (fileName: string, entryType: 'file' | 'directory') => TaskSummary[];
|
||||||
onRunTask: (task: TaskSummary, entry: DirEntry) => void;
|
onRunTask: (task: TaskSummary, entry: DirEntry) => void;
|
||||||
|
onCreateWorkspace: (entry: DirEntry) => void;
|
||||||
scrollRef: React.RefObject<HTMLDivElement | null>;
|
scrollRef: React.RefObject<HTMLDivElement | null>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -72,6 +73,7 @@ export const FileGrid = ({
|
|||||||
onRenamingChange,
|
onRenamingChange,
|
||||||
getMatchingTasks,
|
getMatchingTasks,
|
||||||
onRunTask,
|
onRunTask,
|
||||||
|
onCreateWorkspace,
|
||||||
scrollRef,
|
scrollRef,
|
||||||
}: FileGridProps) => {
|
}: FileGridProps) => {
|
||||||
const lastClickedIdx = useRef<number>(-1);
|
const lastClickedIdx = useRef<number>(-1);
|
||||||
@@ -153,6 +155,7 @@ export const FileGrid = ({
|
|||||||
onRenamingChange={onRenamingChange}
|
onRenamingChange={onRenamingChange}
|
||||||
matchingTasks={getMatchingTasks(entry.name, entry.type)}
|
matchingTasks={getMatchingTasks(entry.name, entry.type)}
|
||||||
onRunTask={onRunTask}
|
onRunTask={onRunTask}
|
||||||
|
onCreateWorkspace={onCreateWorkspace}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useCallback, useEffect, useMemo, useRef } from 'react';
|
import { useState, useCallback, useEffect, useMemo, useRef } from 'react';
|
||||||
import { Folder, Trash2, Pencil, MoreVertical, MessageSquare, Scissors, Copy, Check, Play, Download } from 'lucide-react';
|
import { Folder, Trash2, Pencil, MoreVertical, MessageSquare, Scissors, Copy, Check, Play, Download, LayoutGrid } from 'lucide-react';
|
||||||
import { getIcon } from 'material-file-icons';
|
import { getIcon } from 'material-file-icons';
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -43,6 +43,7 @@ export type FileItemProps = {
|
|||||||
onRenamingChange: (name: string | null) => void;
|
onRenamingChange: (name: string | null) => void;
|
||||||
matchingTasks: TaskSummary[];
|
matchingTasks: TaskSummary[];
|
||||||
onRunTask: (task: TaskSummary, entry: DirEntry) => void;
|
onRunTask: (task: TaskSummary, entry: DirEntry) => void;
|
||||||
|
onCreateWorkspace: (entry: DirEntry) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
function formatSize(bytes: number): string {
|
function formatSize(bytes: number): string {
|
||||||
@@ -70,6 +71,7 @@ type MenuItemsProps = {
|
|||||||
onCopy: () => void;
|
onCopy: () => void;
|
||||||
matchingTasks: TaskSummary[];
|
matchingTasks: TaskSummary[];
|
||||||
onRunTask: (task: TaskSummary, entry: DirEntry) => void;
|
onRunTask: (task: TaskSummary, entry: DirEntry) => void;
|
||||||
|
onCreateWorkspace: (e: DirEntry) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const DropdownMenuItems = ({
|
const DropdownMenuItems = ({
|
||||||
@@ -83,6 +85,7 @@ const DropdownMenuItems = ({
|
|||||||
onCopy,
|
onCopy,
|
||||||
matchingTasks,
|
matchingTasks,
|
||||||
onRunTask,
|
onRunTask,
|
||||||
|
onCreateWorkspace,
|
||||||
}: MenuItemsProps) => (
|
}: MenuItemsProps) => (
|
||||||
<>
|
<>
|
||||||
<DropdownMenuItem onClick={() => onChat(entry)} className="cursor-pointer">
|
<DropdownMenuItem onClick={() => onChat(entry)} className="cursor-pointer">
|
||||||
@@ -108,6 +111,15 @@ const DropdownMenuItems = ({
|
|||||||
</DropdownMenuSubContent>
|
</DropdownMenuSubContent>
|
||||||
</DropdownMenuSub>
|
</DropdownMenuSub>
|
||||||
)}
|
)}
|
||||||
|
{entry.type === 'directory' && (
|
||||||
|
<>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem onClick={() => onCreateWorkspace(entry)} className="cursor-pointer">
|
||||||
|
<LayoutGrid className="mr-2 h-4 w-4" />
|
||||||
|
Create Workspace here
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem onClick={onCut} className="cursor-pointer">
|
<DropdownMenuItem onClick={onCut} className="cursor-pointer">
|
||||||
<Scissors className="mr-2 h-4 w-4" />
|
<Scissors className="mr-2 h-4 w-4" />
|
||||||
@@ -142,6 +154,7 @@ const ContextMenuItems = ({
|
|||||||
onCopy,
|
onCopy,
|
||||||
matchingTasks,
|
matchingTasks,
|
||||||
onRunTask,
|
onRunTask,
|
||||||
|
onCreateWorkspace,
|
||||||
}: MenuItemsProps) => (
|
}: MenuItemsProps) => (
|
||||||
<>
|
<>
|
||||||
<ContextMenuItem onClick={() => onChat(entry)} className="cursor-pointer">
|
<ContextMenuItem onClick={() => onChat(entry)} className="cursor-pointer">
|
||||||
@@ -167,6 +180,15 @@ const ContextMenuItems = ({
|
|||||||
</ContextMenuSubContent>
|
</ContextMenuSubContent>
|
||||||
</ContextMenuSub>
|
</ContextMenuSub>
|
||||||
)}
|
)}
|
||||||
|
{entry.type === 'directory' && (
|
||||||
|
<>
|
||||||
|
<ContextMenuSeparator />
|
||||||
|
<ContextMenuItem onClick={() => onCreateWorkspace(entry)} className="cursor-pointer">
|
||||||
|
<LayoutGrid className="mr-2 h-4 w-4" />
|
||||||
|
Create Workspace here
|
||||||
|
</ContextMenuItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<ContextMenuSeparator />
|
<ContextMenuSeparator />
|
||||||
<ContextMenuItem onClick={onCut} className="cursor-pointer">
|
<ContextMenuItem onClick={onCut} className="cursor-pointer">
|
||||||
<Scissors className="mr-2 h-4 w-4" />
|
<Scissors className="mr-2 h-4 w-4" />
|
||||||
@@ -300,6 +322,7 @@ export const FileItem = ({
|
|||||||
onRenamingChange,
|
onRenamingChange,
|
||||||
matchingTasks,
|
matchingTasks,
|
||||||
onRunTask,
|
onRunTask,
|
||||||
|
onCreateWorkspace,
|
||||||
}: FileItemProps) => {
|
}: FileItemProps) => {
|
||||||
const [renaming, setRenaming] = useState(false);
|
const [renaming, setRenaming] = useState(false);
|
||||||
const clickTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const clickTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
@@ -376,6 +399,7 @@ export const FileItem = ({
|
|||||||
onCopy,
|
onCopy,
|
||||||
matchingTasks,
|
matchingTasks,
|
||||||
onRunTask,
|
onRunTask,
|
||||||
|
onCreateWorkspace,
|
||||||
};
|
};
|
||||||
|
|
||||||
const cutOpacity = isCut ? 'opacity-50' : '';
|
const cutOpacity = isCut ? 'opacity-50' : '';
|
||||||
|
|||||||
@@ -33,7 +33,11 @@ type ClipboardState = { paths: string[]; mode: 'copy' | 'cut' } | null;
|
|||||||
|
|
||||||
type HomeRoot = 'home' | '~' | 'officer.dev';
|
type HomeRoot = 'home' | '~' | 'officer.dev';
|
||||||
|
|
||||||
export const Files = () => {
|
type FilesProps = {
|
||||||
|
basePath?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Files = ({ basePath = '/' }: FilesProps) => {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
@@ -86,6 +90,10 @@ export const Files = () => {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (basePath !== '/' && !currentPath.startsWith(basePath)) {
|
||||||
|
setCurrentPath(basePath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
refresh();
|
refresh();
|
||||||
}, [currentPath, homeRoot]);
|
}, [currentPath, homeRoot]);
|
||||||
|
|
||||||
@@ -137,6 +145,10 @@ export const Files = () => {
|
|||||||
const selectedPaths = () => Array.from(selected).map(entryPath);
|
const selectedPaths = () => Array.from(selected).map(entryPath);
|
||||||
|
|
||||||
const handleNavigate = (path: string) => {
|
const handleNavigate = (path: string) => {
|
||||||
|
if (basePath !== '/' && !path.startsWith(basePath)) {
|
||||||
|
setCurrentPath(basePath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setCurrentPath(path);
|
setCurrentPath(path);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -262,6 +274,18 @@ export const Files = () => {
|
|||||||
setRunningTask({ task, entry });
|
setRunningTask({ task, entry });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCreateWorkspace = (entry: DirEntry) => {
|
||||||
|
const folderPath = currentPath === '/' ? `/${entry.name}` : `${currentPath}/${entry.name}`;
|
||||||
|
navigate(`/workspaces/new?name=${encodeURIComponent(entry.name)}&cwd=${encodeURIComponent(folderPath)}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateWorkspaceHere = () => {
|
||||||
|
const dirName = currentPath === '/' ? '' : currentPath.split('/').pop()!;
|
||||||
|
const params = new URLSearchParams({ cwd: currentPath });
|
||||||
|
if (dirName) params.set('name', dirName);
|
||||||
|
navigate(`/workspaces/new?${params}`);
|
||||||
|
};
|
||||||
|
|
||||||
const handleGitClone = async () => {
|
const handleGitClone = async () => {
|
||||||
const url = cloneUrl.trim();
|
const url = cloneUrl.trim();
|
||||||
if (!url) return;
|
if (!url) return;
|
||||||
@@ -509,7 +533,7 @@ export const Files = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Home dir selector (Super Admin only) */}
|
{/* Home dir selector (Super Admin only) */}
|
||||||
{user?.role === 'Super Admin' && (
|
{basePath === '/' && user?.role === 'Super Admin' && (
|
||||||
<div className="shrink-0 border-b border-duck-dark/10 px-4 py-2">
|
<div className="shrink-0 border-b border-duck-dark/10 px-4 py-2">
|
||||||
<RadioGroup
|
<RadioGroup
|
||||||
value={homeRoot}
|
value={homeRoot}
|
||||||
@@ -539,7 +563,7 @@ export const Files = () => {
|
|||||||
|
|
||||||
{/* Breadcrumb */}
|
{/* Breadcrumb */}
|
||||||
<div className="shrink-0 border-b border-duck-dark/10 px-4 py-2">
|
<div className="shrink-0 border-b border-duck-dark/10 px-4 py-2">
|
||||||
<Breadcrumb path={currentPath} onNavigate={handleNavigate} />
|
<Breadcrumb path={currentPath} onNavigate={handleNavigate} basePath={basePath} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Upload progress */}
|
{/* Upload progress */}
|
||||||
@@ -625,6 +649,7 @@ export const Files = () => {
|
|||||||
onRenamingChange={setRenamingName}
|
onRenamingChange={setRenamingName}
|
||||||
getMatchingTasks={getMatchingTasks}
|
getMatchingTasks={getMatchingTasks}
|
||||||
onRunTask={handleRunTask}
|
onRunTask={handleRunTask}
|
||||||
|
onCreateWorkspace={handleCreateWorkspace}
|
||||||
scrollRef={fileScrollRef}
|
scrollRef={fileScrollRef}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -645,6 +670,10 @@ export const Files = () => {
|
|||||||
<FolderPlus className="mr-2 h-4 w-4" />
|
<FolderPlus className="mr-2 h-4 w-4" />
|
||||||
New folder
|
New folder
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
|
<ContextMenuItem onClick={handleCreateWorkspaceHere} className="cursor-pointer">
|
||||||
|
<LayoutGrid className="mr-2 h-4 w-4" />
|
||||||
|
Create Workspace here
|
||||||
|
</ContextMenuItem>
|
||||||
</ContextMenuContent>
|
</ContextMenuContent>
|
||||||
</ContextMenu>
|
</ContextMenu>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -5,8 +5,19 @@ import { useQueryClient } from '@tanstack/react-query';
|
|||||||
import { useGlobal } from 'hooks/useGlobal';
|
import { useGlobal } from 'hooks/useGlobal';
|
||||||
import { useClient } from 'hooks/useClient';
|
import { useClient } from 'hooks/useClient';
|
||||||
import { generateSlug } from 'helpers/slug';
|
import { generateSlug } from 'helpers/slug';
|
||||||
|
import { useUserState } from '@/state/useUserState';
|
||||||
import { useWorkspacesState } from '@/state/useWorkspacesState';
|
import { useWorkspacesState } from '@/state/useWorkspacesState';
|
||||||
import type { WorkspaceDefinition } from '@/components/Workspace';
|
import type { WorkspaceDefinition } from '@/components/Workspace';
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from '@/components/ui/alert-dialog';
|
||||||
import {
|
import {
|
||||||
SELECTED_WORKSPACE_KEY,
|
SELECTED_WORKSPACE_KEY,
|
||||||
CREATING_WORKSPACE_KEY,
|
CREATING_WORKSPACE_KEY,
|
||||||
@@ -28,8 +39,10 @@ export const WorkspaceListApp = () => {
|
|||||||
const [, setName] = useGlobal<string>(NEW_WS_NAME_KEY, '');
|
const [, setName] = useGlobal<string>(NEW_WS_NAME_KEY, '');
|
||||||
const [, setDescription] = useGlobal<string>(NEW_WS_DESC_KEY, '');
|
const [, setDescription] = useGlobal<string>(NEW_WS_DESC_KEY, '');
|
||||||
const [, setTemplateIdx] = useGlobal<number>(NEW_WS_TEMPLATE_KEY, 0);
|
const [, setTemplateIdx] = useGlobal<number>(NEW_WS_TEMPLATE_KEY, 0);
|
||||||
|
const [, setFilePath] = useUserState<string>('files/currentPath', '/');
|
||||||
|
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
|
const [deleting, setDeleting] = useState<WorkspaceDefinition | null>(null);
|
||||||
const isWorkspacesPage = location.pathname === '/workspaces';
|
const isWorkspacesPage = location.pathname === '/workspaces';
|
||||||
const filtered = search
|
const filtered = search
|
||||||
? workspaces.filter((ws) => {
|
? workspaces.filter((ws) => {
|
||||||
@@ -46,18 +59,26 @@ export const WorkspaceListApp = () => {
|
|||||||
setName(ws.name);
|
setName(ws.name);
|
||||||
setDescription(ws.description ?? '');
|
setDescription(ws.description ?? '');
|
||||||
setTemplateIdx(ws.templateIdx ?? 0);
|
setTemplateIdx(ws.templateIdx ?? 0);
|
||||||
|
const cwdPath = !ws.cwd || ws.cwd === '~' ? '/' : ws.cwd.replace(/^~\//, '/');
|
||||||
|
setFilePath(cwdPath);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = (ev: React.MouseEvent, ws: WorkspaceDefinition) => {
|
const handleDelete = (ev: React.MouseEvent, ws: WorkspaceDefinition) => {
|
||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
setWorkspaces((prev) => prev.filter((w) => w.id !== ws.id));
|
setDeleting(ws);
|
||||||
if (selected === ws.id) setSelected(null);
|
};
|
||||||
|
|
||||||
const layoutKey = `ws-layout-${ws.id}`;
|
const confirmDelete = () => {
|
||||||
|
if (!deleting) return;
|
||||||
|
setWorkspaces((prev) => prev.filter((w) => w.id !== deleting.id));
|
||||||
|
if (selected === deleting.id) setSelected(null);
|
||||||
|
|
||||||
|
const layoutKey = `ws-layout-${deleting.id}`;
|
||||||
const currentState = queryClient.getQueryData<Record<string, unknown>>(['WORKSPACES_STATE']) ?? {};
|
const currentState = queryClient.getQueryData<Record<string, unknown>>(['WORKSPACES_STATE']) ?? {};
|
||||||
const { [layoutKey]: _, ...rest } = currentState;
|
const { [layoutKey]: _, ...rest } = currentState;
|
||||||
queryClient.setQueryData(['WORKSPACES_STATE'], rest);
|
queryClient.setQueryData(['WORKSPACES_STATE'], rest);
|
||||||
client.patch('/user/workspaces-state', { [layoutKey]: null }).catch(() => {});
|
client.patch('/user/workspaces-state', { [layoutKey]: null }).catch(() => {});
|
||||||
|
setDeleting(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClick = (ws: WorkspaceDefinition) => {
|
const handleClick = (ws: WorkspaceDefinition) => {
|
||||||
@@ -88,6 +109,7 @@ export const WorkspaceListApp = () => {
|
|||||||
setName(generateSlug());
|
setName(generateSlug());
|
||||||
setDescription('');
|
setDescription('');
|
||||||
setTemplateIdx(0);
|
setTemplateIdx(0);
|
||||||
|
setFilePath('/');
|
||||||
setCreating(true);
|
setCreating(true);
|
||||||
if (!isWorkspacesPage) navigate('/workspaces');
|
if (!isWorkspacesPage) navigate('/workspaces');
|
||||||
}}
|
}}
|
||||||
@@ -146,6 +168,23 @@ export const WorkspaceListApp = () => {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<AlertDialog open={deleting !== null} onOpenChange={(open) => { if (!open) setDeleting(null); }}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Delete workspace</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Are you sure you want to delete <strong>{deleting?.name}</strong>? This action cannot be undone.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={confirmDelete} className="bg-red-600 hover:bg-red-700">
|
||||||
|
Delete
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
import { Link, Navigate, useNavigate, useSearchParams } from 'react-router';
|
import { Link, Navigate, useNavigate, useSearchParams } from 'react-router';
|
||||||
import { LayoutGrid, Plus, ArrowRight, Type, Rocket, FileText, Layout } from 'lucide-react';
|
import { FolderOpen, LayoutGrid, Plus, ArrowRight, Type, Rocket, FileText, Layout } from 'lucide-react';
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { useGlobal } from 'hooks/useGlobal';
|
import { useGlobal } from 'hooks/useGlobal';
|
||||||
import { useClient } from 'hooks/useClient';
|
import { useClient } from 'hooks/useClient';
|
||||||
@@ -9,6 +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 '../Files';
|
||||||
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';
|
||||||
|
|
||||||
@@ -389,6 +391,14 @@ const CreatePanel = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// --- CwdFileBrowser (basePath-constrained file browser for workspace form) ---
|
||||||
|
|
||||||
|
const CwdFileBrowser = () => {
|
||||||
|
const [currentPath] = useUserState<string>('files/currentPath', '/');
|
||||||
|
const [basePath] = useState(currentPath);
|
||||||
|
return <Files basePath={basePath} />;
|
||||||
|
};
|
||||||
|
|
||||||
// --- New Workspace Layout ---
|
// --- New Workspace Layout ---
|
||||||
|
|
||||||
const newWsLayout: LayoutNode = {
|
const newWsLayout: LayoutNode = {
|
||||||
@@ -403,7 +413,7 @@ const newWsLayout: LayoutNode = {
|
|||||||
direction: 'horizontal',
|
direction: 'horizontal',
|
||||||
children: [
|
children: [
|
||||||
{ node: { type: 'panel', id: 'new-ws-name', appType: 'new-ws-name' }, size: 30 },
|
{ node: { type: 'panel', id: 'new-ws-name', appType: 'new-ws-name' }, size: 30 },
|
||||||
{ node: { type: 'panel', id: 'new-ws-dir', appType: 'file-browser' }, size: 70 },
|
{ node: { type: 'panel', id: 'new-ws-dir', appType: 'file-browser-cwd' }, size: 70 },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
size: 50,
|
size: 50,
|
||||||
@@ -425,6 +435,7 @@ const newWsLayout: LayoutNode = {
|
|||||||
|
|
||||||
const newWsRegistry = {
|
const newWsRegistry = {
|
||||||
...appRegistry,
|
...appRegistry,
|
||||||
|
'file-browser-cwd': { name: 'File Browser', icon: FolderOpen, component: CwdFileBrowser },
|
||||||
'new-ws-name': { name: 'Name', icon: Type, component: NamePanel },
|
'new-ws-name': { name: 'Name', icon: Type, component: NamePanel },
|
||||||
'new-ws-template': { name: 'Template', icon: Layout, component: TemplatePanel },
|
'new-ws-template': { name: 'Template', icon: Layout, component: TemplatePanel },
|
||||||
'new-ws-create': { name: 'Create', icon: Rocket, component: CreatePanel },
|
'new-ws-create': { name: 'Create', icon: Rocket, component: CreatePanel },
|
||||||
@@ -440,6 +451,7 @@ const WorkspacePreviewEmpty = () => {
|
|||||||
const [, setName] = useGlobal<string>(NEW_WS_NAME_KEY, '');
|
const [, setName] = useGlobal<string>(NEW_WS_NAME_KEY, '');
|
||||||
const [, setDescription] = useGlobal<string>(NEW_WS_DESC_KEY, '');
|
const [, setDescription] = useGlobal<string>(NEW_WS_DESC_KEY, '');
|
||||||
const [, setTemplateIdx] = useGlobal<number>(NEW_WS_TEMPLATE_KEY, 0);
|
const [, setTemplateIdx] = useGlobal<number>(NEW_WS_TEMPLATE_KEY, 0);
|
||||||
|
const [, setFilePath] = useUserState<string>('files/currentPath', '/');
|
||||||
|
|
||||||
if (creating || editingId) return <NewWorkspaceForm />;
|
if (creating || editingId) return <NewWorkspaceForm />;
|
||||||
|
|
||||||
@@ -447,6 +459,7 @@ const WorkspacePreviewEmpty = () => {
|
|||||||
setName(generateSlug());
|
setName(generateSlug());
|
||||||
setDescription('');
|
setDescription('');
|
||||||
setTemplateIdx(0);
|
setTemplateIdx(0);
|
||||||
|
setFilePath('/');
|
||||||
setCreating(true);
|
setCreating(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -505,6 +518,7 @@ export const WorkspaceListScreen = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const NewWorkspaceRedirect = () => {
|
export const NewWorkspaceRedirect = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
const [params] = useSearchParams();
|
const [params] = useSearchParams();
|
||||||
const [, setName] = useGlobal<string>(NEW_WS_NAME_KEY, '');
|
const [, setName] = useGlobal<string>(NEW_WS_NAME_KEY, '');
|
||||||
const [, setDescription] = useGlobal<string>(NEW_WS_DESC_KEY, '');
|
const [, setDescription] = useGlobal<string>(NEW_WS_DESC_KEY, '');
|
||||||
@@ -514,16 +528,16 @@ export const NewWorkspaceRedirect = () => {
|
|||||||
const [, setSelected] = useGlobal<string | null>(SELECTED_WORKSPACE_KEY, null);
|
const [, setSelected] = useGlobal<string | null>(SELECTED_WORKSPACE_KEY, null);
|
||||||
const [, setFilePath] = useUserState<string>('files/currentPath', '/');
|
const [, setFilePath] = useUserState<string>('files/currentPath', '/');
|
||||||
|
|
||||||
const name = params.get('name');
|
useEffect(() => {
|
||||||
const cwd = params.get('cwd');
|
setSelected(null);
|
||||||
|
setEditing(null);
|
||||||
|
setName(params.get('name') || generateSlug());
|
||||||
|
setDescription('');
|
||||||
|
setTemplateIdx(0);
|
||||||
|
setFilePath(params.get('cwd') || '/');
|
||||||
|
setCreating(true);
|
||||||
|
navigate('/workspaces', { replace: true });
|
||||||
|
}, []);
|
||||||
|
|
||||||
setSelected(null);
|
return null;
|
||||||
setEditing(null);
|
|
||||||
setName(name || generateSlug());
|
|
||||||
setDescription('');
|
|
||||||
setTemplateIdx(0);
|
|
||||||
setCreating(true);
|
|
||||||
if (cwd) setFilePath(cwd);
|
|
||||||
|
|
||||||
return <Navigate to="/workspaces" replace />;
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { MessageSquare, FolderOpen, History, Music, Code, TerminalSquare, Monitor, LayoutGrid, LayoutDashboard, Sparkles } from 'lucide-react';
|
import { MessageSquare, FolderOpen, History, Music, Code, TerminalSquare, Monitor, LayoutGrid, LayoutDashboard, Sparkles } from 'lucide-react';
|
||||||
import { useAuth } from 'hooks/useAuth';
|
import { useAuth } from 'hooks/useAuth';
|
||||||
import type { AppRegistry } from '@/components/Workspace';
|
import type { AppRegistry } from '@/components/Workspace';
|
||||||
@@ -39,12 +39,24 @@ const OpenCodeChatWidget = ({ onProviderChange }: { onProviderChange: (p: 'claud
|
|||||||
return <ChatPanel chat={opencode} provider="opencode" availableModels={models} onProviderChange={onProviderChange} />;
|
return <ChatPanel chat={opencode} provider="opencode" availableModels={models} onProviderChange={onProviderChange} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const cwdToPath = (cwd: string) => (cwd === '~' ? '/' : cwd.slice(1));
|
||||||
|
|
||||||
|
const FileBrowserWrapper = () => {
|
||||||
|
const { cwd } = useWorkspace();
|
||||||
|
const basePath = cwdToPath(cwd);
|
||||||
|
return <Files basePath={basePath} />;
|
||||||
|
};
|
||||||
|
|
||||||
const CodeEditorWrapper = () => <CodeEditorView className="h-full w-full" />;
|
const CodeEditorWrapper = () => <CodeEditorView className="h-full w-full" />;
|
||||||
|
|
||||||
|
const EMPTY_TERMINALS: Record<string, string> = {};
|
||||||
|
|
||||||
const TerminalWrapper = ({ panelId }: { panelId: string }) => {
|
const TerminalWrapper = ({ panelId }: { panelId: string }) => {
|
||||||
const { workspaceId } = useWorkspace();
|
const { workspaceId, cwd } = useWorkspace();
|
||||||
const stateKey = workspaceId ? `ws-terminals-${workspaceId}` : 'ws-terminals-default';
|
const stateKey = workspaceId ? `ws-terminals-${workspaceId}` : 'ws-terminals-default';
|
||||||
const [terminals, setTerminals] = useWorkspacesState<Record<string, string>>(stateKey, {});
|
const [terminals, setTerminals] = useWorkspacesState<Record<string, string>>(stateKey, EMPTY_TERMINALS);
|
||||||
|
const setTerminalsRef = useRef(setTerminals);
|
||||||
|
setTerminalsRef.current = setTerminals;
|
||||||
|
|
||||||
const sessionId = terminals[panelId];
|
const sessionId = terminals[panelId];
|
||||||
|
|
||||||
@@ -54,16 +66,27 @@ const TerminalWrapper = ({ panelId }: { panelId: string }) => {
|
|||||||
}
|
}
|
||||||
}, [panelId, sessionId, setTerminals]);
|
}, [panelId, sessionId, setTerminals]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
setTerminalsRef.current((prev) => {
|
||||||
|
const { [panelId]: _, ...rest } = prev;
|
||||||
|
return rest;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}, [panelId]);
|
||||||
|
|
||||||
if (!sessionId) return null;
|
if (!sessionId) return null;
|
||||||
|
|
||||||
return <TerminalView className="h-full w-full p-2" sessionId={sessionId} />;
|
return <TerminalView className="h-full w-full p-2" sessionId={sessionId} cwd={cwd} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
const HostTerminalWrapper = ({ panelId }: { panelId: string }) => {
|
const HostTerminalWrapper = ({ panelId }: { panelId: string }) => {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const { workspaceId } = useWorkspace();
|
const { workspaceId, cwd } = useWorkspace();
|
||||||
const stateKey = workspaceId ? `ws-host-terminals-${workspaceId}` : 'ws-host-terminals-default';
|
const stateKey = workspaceId ? `ws-host-terminals-${workspaceId}` : 'ws-host-terminals-default';
|
||||||
const [terminals, setTerminals] = useWorkspacesState<Record<string, string>>(stateKey, {});
|
const [terminals, setTerminals] = useWorkspacesState<Record<string, string>>(stateKey, EMPTY_TERMINALS);
|
||||||
|
const setTerminalsRef = useRef(setTerminals);
|
||||||
|
setTerminalsRef.current = setTerminals;
|
||||||
|
|
||||||
const sessionId = terminals[panelId];
|
const sessionId = terminals[panelId];
|
||||||
|
|
||||||
@@ -73,6 +96,15 @@ const HostTerminalWrapper = ({ panelId }: { panelId: string }) => {
|
|||||||
}
|
}
|
||||||
}, [panelId, sessionId, setTerminals]);
|
}, [panelId, sessionId, setTerminals]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
setTerminalsRef.current((prev) => {
|
||||||
|
const { [panelId]: _, ...rest } = prev;
|
||||||
|
return rest;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}, [panelId]);
|
||||||
|
|
||||||
if (user?.role !== 'Super Admin') {
|
if (user?.role !== 'Super Admin') {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full w-full items-center justify-center text-sm text-muted-foreground">
|
<div className="flex h-full w-full items-center justify-center text-sm text-muted-foreground">
|
||||||
@@ -83,12 +115,12 @@ const HostTerminalWrapper = ({ panelId }: { panelId: string }) => {
|
|||||||
|
|
||||||
if (!sessionId) return null;
|
if (!sessionId) return null;
|
||||||
|
|
||||||
return <TerminalView className="h-full w-full p-2" sessionId={sessionId} sandboxed={false} />;
|
return <TerminalView className="h-full w-full p-2" sessionId={sessionId} sandboxed={false} cwd={cwd} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const appRegistry: AppRegistry = {
|
export const appRegistry: AppRegistry = {
|
||||||
'chat': { name: 'Chat', icon: MessageSquare, component: ChatWidget },
|
'chat': { name: 'Chat', icon: MessageSquare, component: ChatWidget },
|
||||||
'file-browser': { name: 'File Browser', icon: FolderOpen, component: () => <Files /> },
|
'file-browser': { name: 'File Browser', icon: FolderOpen, component: FileBrowserWrapper },
|
||||||
'chat-history': { name: 'Chat History', icon: History, component: () => <ChatHistory /> },
|
'chat-history': { name: 'Chat History', icon: History, component: () => <ChatHistory /> },
|
||||||
'sound-library': { name: 'Sound Library', icon: Music, component: () => <Catalog /> },
|
'sound-library': { name: 'Sound Library', icon: Music, component: () => <Catalog /> },
|
||||||
'code-editor': { name: 'Code Editor', icon: Code, component: CodeEditorWrapper },
|
'code-editor': { name: 'Code Editor', icon: Code, component: CodeEditorWrapper },
|
||||||
|
|||||||
+3
-1
@@ -19,6 +19,7 @@ type WSData = {
|
|||||||
provider: 'claude' | 'opencode' | 'terminal';
|
provider: 'claude' | 'opencode' | 'terminal';
|
||||||
sandboxed: boolean;
|
sandboxed: boolean;
|
||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
|
cwd?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlers: Record<string, typeof claudeWebsocket> = {
|
const handlers: Record<string, typeof claudeWebsocket> = {
|
||||||
@@ -45,8 +46,9 @@ async function upgradeWs(req: Request, server: any, provider: 'claude' | 'openco
|
|||||||
const url = new URL(req.url);
|
const url = new URL(req.url);
|
||||||
const sessionId = url.searchParams.get('sessionId') ?? undefined;
|
const sessionId = url.searchParams.get('sessionId') ?? undefined;
|
||||||
const sandboxed = url.searchParams.get('sandboxed') !== 'false';
|
const sandboxed = url.searchParams.get('sandboxed') !== 'false';
|
||||||
|
const cwd = url.searchParams.get('cwd') ?? undefined;
|
||||||
const ok = server.upgrade(req, {
|
const ok = server.upgrade(req, {
|
||||||
data: { userId: user.id, email: user.email, role: user.role, provider, sandboxed, sessionId },
|
data: { userId: user.id, email: user.email, role: user.role, provider, sandboxed, sessionId, cwd },
|
||||||
});
|
});
|
||||||
if (!ok) return new Response('Upgrade failed', { status: 500 });
|
if (!ok) return new Response('Upgrade failed', { status: 500 });
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -1,12 +1,25 @@
|
|||||||
FROM imbios/bun-node:22-slim
|
FROM imbios/bun-node:22-slim
|
||||||
|
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
&& apt-get install -y python3 make g++ zsh git curl ca-certificates fortune-mod cowsay \
|
&& apt-get install -y \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
python3 make g++ zsh git curl wget ca-certificates \
|
||||||
|
fortune-mod cowsay sudo gosu \
|
||||||
|
zip unzip tree btop net-tools tmux \
|
||||||
|
&& apt-get clean
|
||||||
|
|
||||||
|
RUN curl -LO https://github.com/neovim/neovim/releases/latest/download/nvim-linux-x86_64.tar.gz \
|
||||||
|
&& tar -C /opt -xzf nvim-linux-x86_64.tar.gz \
|
||||||
|
&& rm nvim-linux-x86_64.tar.gz
|
||||||
|
|
||||||
|
ENV PATH="/opt/nvim-linux-x86_64/bin:${PATH}"
|
||||||
|
|
||||||
|
RUN git clone --depth 1 https://github.com/LazyVim/starter /opt/lazyvim-starter \
|
||||||
|
&& rm -rf /opt/lazyvim-starter/.git
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY pty-sidecar.mjs /app/pty-sidecar.mjs
|
COPY pty-sidecar.mjs /app/pty-sidecar.mjs
|
||||||
|
COPY entrypoint.sh /app/entrypoint.sh
|
||||||
COPY templates /opt/terminal-templates
|
COPY templates /opt/terminal-templates
|
||||||
|
|
||||||
RUN npm init -y \
|
RUN npm init -y \
|
||||||
@@ -23,6 +36,7 @@ RUN curl -fsSL "https://github.com/eza-community/eza/releases/download/v${EZA_VE
|
|||||||
&& chmod +x /usr/local/bin/eza \
|
&& chmod +x /usr/local/bin/eza \
|
||||||
&& rm -rf /tmp/eza.tar.gz /tmp/completions /tmp/man
|
&& rm -rf /tmp/eza.tar.gz /tmp/completions /tmp/man
|
||||||
|
|
||||||
|
RUN mkdir -p /home/officer
|
||||||
|
|
||||||
WORKDIR /home/officer
|
WORKDIR /home/officer
|
||||||
|
|
||||||
@@ -32,4 +46,4 @@ ENV PATH="/usr/games:${PATH}"
|
|||||||
|
|
||||||
EXPOSE 5337
|
EXPOSE 5337
|
||||||
|
|
||||||
CMD ["node", "/app/pty-sidecar.mjs"]
|
ENTRYPOINT ["/app/entrypoint.sh"]
|
||||||
|
|||||||
Executable
+34
@@ -0,0 +1,34 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
USERNAME="${TERMINAL_USER:-officer}"
|
||||||
|
USER_UID="${TERMINAL_UID:-1000}"
|
||||||
|
USER_GID="${TERMINAL_GID:-1000}"
|
||||||
|
|
||||||
|
# Remove any existing user/group with the target UID/GID
|
||||||
|
EXISTING_USER=$(getent passwd "$USER_UID" | cut -d: -f1)
|
||||||
|
if [ -n "$EXISTING_USER" ] && [ "$EXISTING_USER" != "$USERNAME" ]; then
|
||||||
|
userdel "$EXISTING_USER" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
EXISTING_GROUP=$(getent group "$USER_GID" | cut -d: -f1)
|
||||||
|
if [ -n "$EXISTING_GROUP" ] && [ "$EXISTING_GROUP" != "$USERNAME" ]; then
|
||||||
|
groupdel "$EXISTING_GROUP" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create group and user
|
||||||
|
groupadd -g "$USER_GID" "$USERNAME" 2>/dev/null || true
|
||||||
|
useradd -u "$USER_UID" -g "$USER_GID" -s /bin/zsh -d /home/officer "$USERNAME" 2>/dev/null || true
|
||||||
|
|
||||||
|
# Passwordless sudo
|
||||||
|
echo "$USERNAME ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/terminal-user
|
||||||
|
chmod 0440 /etc/sudoers.d/terminal-user
|
||||||
|
|
||||||
|
# Seed LazyVim config if not present
|
||||||
|
if [ ! -d /home/officer/.config/nvim ]; then
|
||||||
|
mkdir -p /home/officer/.config
|
||||||
|
cp -r /opt/lazyvim-starter /home/officer/.config/nvim
|
||||||
|
chown -R "$USER_UID:$USER_GID" /home/officer/.config
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Run sidecar as the user
|
||||||
|
exec gosu "$USER_UID:$USER_GID" node /app/pty-sidecar.mjs
|
||||||
@@ -45,6 +45,11 @@ const ensureUserFiles = async (homeDir) => {
|
|||||||
await cp(join(templateDir, '.zshrc'), zshrcPath);
|
await cp(join(templateDir, '.zshrc'), zshrcPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const tmuxconfPath = join(homeDir, '.tmux.conf');
|
||||||
|
if (!existsSync(tmuxconfPath)) {
|
||||||
|
await cp(join(templateDir, '.tmux.conf'), tmuxconfPath);
|
||||||
|
}
|
||||||
|
|
||||||
const starshipPath = join(homeDir, '.config', 'starship-officer.toml');
|
const starshipPath = join(homeDir, '.config', 'starship-officer.toml');
|
||||||
if (!existsSync(starshipPath)) {
|
if (!existsSync(starshipPath)) {
|
||||||
await cp(join(templateDir, 'starship-officer.toml'), starshipPath);
|
await cp(join(templateDir, 'starship-officer.toml'), starshipPath);
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
########## TPM AUTO-INSTALL + SESSION PERSISTENCE ##########
|
||||||
|
|
||||||
|
# Auto-install TPM if missing
|
||||||
|
if-shell '[ ! -d ~/.tmux/plugins/tpm ]' \
|
||||||
|
'run-shell "git clone https://github.com/tmux-plugins/tpm ~/.tmux/plugins/tpm"'
|
||||||
|
|
||||||
|
# Plugin list
|
||||||
|
set -g @plugin 'tmux-plugins/tpm'
|
||||||
|
|
||||||
|
# remap prefix from 'C-b' to 'C-a'
|
||||||
|
unbind C-b
|
||||||
|
set-option -g prefix C-a
|
||||||
|
bind-key C-a send-prefix
|
||||||
|
|
||||||
|
set -g base-index 1
|
||||||
|
|
||||||
|
# split panes using | and -
|
||||||
|
unbind '"'
|
||||||
|
unbind %
|
||||||
|
bind | split-window -h
|
||||||
|
bind - split-window -v
|
||||||
|
|
||||||
|
# reload config file (change file location to your the tmux.conf you want to use)
|
||||||
|
unbind r
|
||||||
|
bind r source-file ~/.tmux.conf \; display-message "Config reloaded!" \; refresh-client -S
|
||||||
|
|
||||||
|
# switch panes using Alt-arrow without prefix
|
||||||
|
bind -n M-Left select-pane -L
|
||||||
|
bind -n M-Right select-pane -R
|
||||||
|
bind -n M-Up select-pane -U
|
||||||
|
bind -n M-Down select-pane -D
|
||||||
|
# switch panes using Alt-HJKL without prefix
|
||||||
|
bind -n M-h select-pane -L
|
||||||
|
bind -n M-l select-pane -R
|
||||||
|
bind -n M-k select-pane -U
|
||||||
|
bind -n M-j select-pane -D
|
||||||
|
|
||||||
|
# Enable mouse control (clickable windows, panes, resizable panes)
|
||||||
|
|
||||||
|
# don't rename windows automatically
|
||||||
|
set-option -g allow-rename off
|
||||||
|
|
||||||
|
######################
|
||||||
|
### DESIGN CHANGES ###
|
||||||
|
######################
|
||||||
|
|
||||||
|
# loud or quiet?
|
||||||
|
set -g visual-activity off
|
||||||
|
set -g visual-bell off
|
||||||
|
set -g visual-silence off
|
||||||
|
setw -g monitor-activity off
|
||||||
|
set -g bell-action none
|
||||||
|
|
||||||
|
# modes
|
||||||
|
setw -g clock-mode-colour colour12
|
||||||
|
setw -g mode-style 'fg=colour1 bg=colour18 bold'
|
||||||
|
|
||||||
|
# panes
|
||||||
|
set -g pane-border-style 'fg=colour19 bg=colour0'
|
||||||
|
set -g pane-active-border-style 'bg=colour0 fg=colour9'
|
||||||
|
|
||||||
|
# statusbar
|
||||||
|
set -g status-position bottom
|
||||||
|
set -g status-justify left
|
||||||
|
set -g status-style 'bg=colour2 fg=colour23'
|
||||||
|
# set -g status-left '#[fg=white,bg=black,bold] pastilhas #[default]'
|
||||||
|
set -g status-left '#[fg=#ffffff,bg=#000000,bold] #{USER}@#H #[default]'
|
||||||
|
# set -g status-left-length 20
|
||||||
|
set -g status-right '#[fg=#ffffff,bg=colour1] %d/%m #[fg=#ffffff,bg=colour8] %H:%M:%S '
|
||||||
|
set -g status-right-length 50
|
||||||
|
set -g status-left-length 20
|
||||||
|
|
||||||
|
setw -g window-status-current-style 'fg=colour1 bg=colour19 bold'
|
||||||
|
setw -g window-status-current-format ' #I#[fg=colour249]:#[fg=colour255]#W#[fg=colour249]#F '
|
||||||
|
|
||||||
|
setw -g window-status-style 'fg=colour9 bg=colour18'
|
||||||
|
setw -g window-status-format ' #I#[fg=colour237]:#[fg=colour250]#W#[fg=colour244]#F '
|
||||||
|
|
||||||
|
setw -g window-status-bell-style 'fg=colour255 bg=colour1 bold'
|
||||||
|
# ...existing code...
|
||||||
|
# messages
|
||||||
|
set -g message-style 'fg=#ffffff bg=red bold'
|
||||||
|
# Change the font color for the exit pane confirmation message
|
||||||
|
set -g message-command-style 'fg=#ffffff bg=red bold'
|
||||||
|
|
||||||
|
# ...existing code...
|
||||||
|
|
||||||
|
# messages
|
||||||
|
# set -g message-style 'fg=colour232 bg=colour16 bold'
|
||||||
|
|
||||||
|
##########################
|
||||||
|
### END DESIGN CHANGES ###
|
||||||
|
##########################
|
||||||
|
|
||||||
|
##########################
|
||||||
|
### EASY MOUSE SCROLL ###
|
||||||
|
##########################
|
||||||
|
|
||||||
|
set -g mouse on
|
||||||
|
set -ga terminal-overrides ',*256color*:smcup@:rmcup@'
|
||||||
@@ -5,13 +5,14 @@ import { fileURLToPath } from 'node:url';
|
|||||||
import { getHomeDir } from '@@/data-path';
|
import { getHomeDir } from '@@/data-path';
|
||||||
import { officerdb, Users } from 'officerdb';
|
import { officerdb, Users } from 'officerdb';
|
||||||
|
|
||||||
type WSData = { userId: number; email: string; role: string; sandboxed: boolean; sessionId?: string };
|
type WSData = { userId: number; email: string; role: string; sandboxed: boolean; sessionId?: string; cwd?: string };
|
||||||
type ShellInfo = { command: string; args: string[]; name: string };
|
type ShellInfo = { command: string; args: string[]; name: string };
|
||||||
type BridgeSession = {
|
type BridgeSession = {
|
||||||
client: ServerWebSocket<WSData>;
|
client: ServerWebSocket<WSData>;
|
||||||
sidecar: WebSocket | null;
|
sidecar: WebSocket | null;
|
||||||
dockerId: string;
|
dockerId: string;
|
||||||
port: number;
|
port: number;
|
||||||
|
pendingMessages: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type ContainerInfo = {
|
type ContainerInfo = {
|
||||||
@@ -99,7 +100,7 @@ const ensureDockerImage = () => {
|
|||||||
dockerImageReady = true;
|
dockerImageReady = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
const startDockerSidecar = (port: number, homeDir: string, userId: number): { dockerId: string } => {
|
const startDockerSidecar = (port: number, homeDir: string, userId: number, email: string): { dockerId: string } => {
|
||||||
ensureDockerImage();
|
ensureDockerImage();
|
||||||
const dockerPath = Bun.which('docker') ?? 'docker';
|
const dockerPath = Bun.which('docker') ?? 'docker';
|
||||||
const dockerId = `officer-terminal-${userId}`;
|
const dockerId = `officer-terminal-${userId}`;
|
||||||
@@ -110,12 +111,15 @@ const startDockerSidecar = (port: number, homeDir: string, userId: number): { do
|
|||||||
Bun.spawnSync({ cmd: [dockerPath, 'rm', '-f', dockerId], stdout: 'ignore', stderr: 'ignore' });
|
Bun.spawnSync({ cmd: [dockerPath, 'rm', '-f', dockerId], stdout: 'ignore', stderr: 'ignore' });
|
||||||
}
|
}
|
||||||
|
|
||||||
let userArgs: string[] = [];
|
const username = (email.split('@')[0] ?? 'officer').replace(/[^a-z0-9_-]/g, '_').slice(0, 32);
|
||||||
|
let uid = 1000;
|
||||||
|
let gid = 1000;
|
||||||
try {
|
try {
|
||||||
const stats = statSync(homeDir);
|
const stats = statSync(homeDir);
|
||||||
userArgs = ['--user', `${stats.uid}:${stats.gid}`];
|
uid = stats.uid;
|
||||||
|
gid = stats.gid;
|
||||||
} catch {
|
} catch {
|
||||||
userArgs = [];
|
// fallback to defaults
|
||||||
}
|
}
|
||||||
|
|
||||||
const run = Bun.spawnSync({
|
const run = Bun.spawnSync({
|
||||||
@@ -127,13 +131,18 @@ const startDockerSidecar = (port: number, homeDir: string, userId: number): { do
|
|||||||
dockerId,
|
dockerId,
|
||||||
'--restart',
|
'--restart',
|
||||||
'unless-stopped',
|
'unless-stopped',
|
||||||
...userArgs,
|
|
||||||
'-p',
|
'-p',
|
||||||
`127.0.0.1:${port}:${port}`,
|
`127.0.0.1:${port}:${port}`,
|
||||||
'-e',
|
'-e',
|
||||||
`TERMINAL_PTY_PORT=${port}`,
|
`TERMINAL_PTY_PORT=${port}`,
|
||||||
'-e',
|
'-e',
|
||||||
'TERMINAL_PTY_HOST=0.0.0.0',
|
'TERMINAL_PTY_HOST=0.0.0.0',
|
||||||
|
'-e',
|
||||||
|
`TERMINAL_USER=${username}`,
|
||||||
|
'-e',
|
||||||
|
`TERMINAL_UID=${uid}`,
|
||||||
|
'-e',
|
||||||
|
`TERMINAL_GID=${gid}`,
|
||||||
'-v',
|
'-v',
|
||||||
`${homeDir}:/home/officer`,
|
`${homeDir}:/home/officer`,
|
||||||
'-w',
|
'-w',
|
||||||
@@ -211,7 +220,7 @@ const ensureDockerContainer = async (email: string, userId: number, homeDir: str
|
|||||||
}
|
}
|
||||||
|
|
||||||
const port = existing?.port ?? getAvailablePort(map, userId);
|
const port = existing?.port ?? getAvailablePort(map, userId);
|
||||||
const docker = startDockerSidecar(port, homeDir, userId);
|
const docker = startDockerSidecar(port, homeDir, userId, email);
|
||||||
const next = { userId, email, dockerId: docker.dockerId, port };
|
const next = { userId, email, dockerId: docker.dockerId, port };
|
||||||
map[email] = next;
|
map[email] = next;
|
||||||
await saveContainerMap(map);
|
await saveContainerMap(map);
|
||||||
@@ -268,6 +277,12 @@ export const initTerminalSidecars = async () => {
|
|||||||
const containerShell: ShellInfo = { command: '/bin/zsh', args: ['-d', '-i'], name: 'zsh' };
|
const containerShell: ShellInfo = { command: '/bin/zsh', args: ['-d', '-i'], name: 'zsh' };
|
||||||
const containerHome = '/home/officer';
|
const containerHome = '/home/officer';
|
||||||
|
|
||||||
|
const resolveCwd = (home: string, cwd?: string) => {
|
||||||
|
if (!cwd || cwd === '~') return home;
|
||||||
|
if (cwd.startsWith('~/')) return join(home, cwd.slice(2));
|
||||||
|
return home;
|
||||||
|
};
|
||||||
|
|
||||||
export const terminalWebsocket = {
|
export const terminalWebsocket = {
|
||||||
async open(ws: ServerWebSocket<WSData>) {
|
async open(ws: ServerWebSocket<WSData>) {
|
||||||
const { email, role, sandboxed } = ws.data;
|
const { email, role, sandboxed } = ws.data;
|
||||||
@@ -278,16 +293,20 @@ export const terminalWebsocket = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!sandboxed) {
|
if (!sandboxed) {
|
||||||
|
const session: BridgeSession = { client: ws, sidecar: null, dockerId: '', port: HOST_SIDECAR_PORT, pendingMessages: [] };
|
||||||
|
sessions.set(ws, session);
|
||||||
|
|
||||||
let sidecar: WebSocket | null = null;
|
let sidecar: WebSocket | null = null;
|
||||||
try {
|
try {
|
||||||
sidecar = await connectSidecar(HOST_SIDECAR_PORT);
|
sidecar = await connectSidecar(HOST_SIDECAR_PORT);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : 'Failed to connect host sidecar';
|
const message = err instanceof Error ? err.message : 'Failed to connect host sidecar';
|
||||||
sendOutput(ws, `\r\n[Terminal error] ${message}\r\n`);
|
sendOutput(ws, `\r\n[Terminal error] ${message}\r\n`);
|
||||||
|
sessions.delete(ws);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
sessions.set(ws, { client: ws, sidecar, dockerId: '', port: HOST_SIDECAR_PORT });
|
session.sidecar = sidecar;
|
||||||
|
|
||||||
sidecar.addEventListener('message', (ev) => {
|
sidecar.addEventListener('message', (ev) => {
|
||||||
try {
|
try {
|
||||||
@@ -306,11 +325,14 @@ export const terminalWebsocket = {
|
|||||||
type: 'init',
|
type: 'init',
|
||||||
sessionId: ws.data.sessionId ?? `host-${ws.data.userId}`,
|
sessionId: ws.data.sessionId ?? `host-${ws.data.userId}`,
|
||||||
shell: { command: process.env.SHELL ?? '/bin/zsh', args: ['-i'] },
|
shell: { command: process.env.SHELL ?? '/bin/zsh', args: ['-i'] },
|
||||||
cwd: process.env.HOME,
|
cwd: resolveCwd(process.env.HOME!, ws.data.cwd),
|
||||||
homeDir: process.env.HOME,
|
homeDir: process.env.HOME,
|
||||||
userLabel: email,
|
userLabel: email,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
for (const msg of session.pendingMessages) sidecar.send(msg);
|
||||||
|
session.pendingMessages = [];
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -319,6 +341,9 @@ export const terminalWebsocket = {
|
|||||||
mkdirSync(userRoot, { recursive: true });
|
mkdirSync(userRoot, { recursive: true });
|
||||||
mkdirSync(cwd, { recursive: true });
|
mkdirSync(cwd, { recursive: true });
|
||||||
|
|
||||||
|
const session: BridgeSession = { client: ws, sidecar: null, dockerId: '', port: 0, pendingMessages: [] };
|
||||||
|
sessions.set(ws, session);
|
||||||
|
|
||||||
let sidecar: WebSocket | null = null;
|
let sidecar: WebSocket | null = null;
|
||||||
let info: ContainerInfo | undefined;
|
let info: ContainerInfo | undefined;
|
||||||
try {
|
try {
|
||||||
@@ -335,10 +360,13 @@ export const terminalWebsocket = {
|
|||||||
}
|
}
|
||||||
sendOutput(ws, '\r\n[Process exited]\r\n');
|
sendOutput(ws, '\r\n[Process exited]\r\n');
|
||||||
if (info) stopDockerSidecar(info.dockerId);
|
if (info) stopDockerSidecar(info.dockerId);
|
||||||
|
sessions.delete(ws);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
sessions.set(ws, { client: ws, sidecar, dockerId: info.dockerId, port: info.port });
|
session.sidecar = sidecar;
|
||||||
|
session.dockerId = info.dockerId;
|
||||||
|
session.port = info.port;
|
||||||
|
|
||||||
sidecar.addEventListener('message', (ev) => {
|
sidecar.addEventListener('message', (ev) => {
|
||||||
try {
|
try {
|
||||||
@@ -357,19 +385,28 @@ export const terminalWebsocket = {
|
|||||||
type: 'init',
|
type: 'init',
|
||||||
sessionId: ws.data.sessionId ?? `default-${ws.data.userId}`,
|
sessionId: ws.data.sessionId ?? `default-${ws.data.userId}`,
|
||||||
shell: containerShell,
|
shell: containerShell,
|
||||||
cwd: containerHome,
|
cwd: resolveCwd(containerHome, ws.data.cwd),
|
||||||
homeDir: containerHome,
|
homeDir: containerHome,
|
||||||
userLabel: email,
|
userLabel: email,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
for (const msg of session.pendingMessages) sidecar.send(msg);
|
||||||
|
session.pendingMessages = [];
|
||||||
},
|
},
|
||||||
|
|
||||||
message(ws: ServerWebSocket<WSData>, raw: string | Buffer) {
|
message(ws: ServerWebSocket<WSData>, raw: string | Buffer) {
|
||||||
const session = sessions.get(ws);
|
const session = sessions.get(ws);
|
||||||
if (!session?.sidecar || session.sidecar.readyState !== WebSocket.OPEN) return;
|
if (!session) return;
|
||||||
|
|
||||||
|
const payload = typeof raw === 'string' ? raw : raw.toString();
|
||||||
|
|
||||||
|
if (!session.sidecar || session.sidecar.readyState !== WebSocket.OPEN) {
|
||||||
|
session.pendingMessages.push(payload);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const payload = typeof raw === 'string' ? raw : raw.toString();
|
|
||||||
session.sidecar.send(payload);
|
session.sidecar.send(payload);
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
|
|||||||
@@ -3,23 +3,26 @@ import { ChevronRight, Home } from 'lucide-react';
|
|||||||
type BreadcrumbProps = {
|
type BreadcrumbProps = {
|
||||||
path: string;
|
path: string;
|
||||||
onNavigate: (path: string) => void;
|
onNavigate: (path: string) => void;
|
||||||
|
basePath?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Breadcrumb = ({ path, onNavigate }: BreadcrumbProps) => {
|
export const Breadcrumb = ({ path, onNavigate, basePath = '/' }: BreadcrumbProps) => {
|
||||||
const segments = path.split('/').filter(Boolean);
|
const relativePath = basePath !== '/' && path.startsWith(basePath) ? path.slice(basePath.length) : path;
|
||||||
|
const segments = relativePath.split('/').filter(Boolean);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="flex items-center gap-1 text-sm flex-wrap">
|
<nav className="flex items-center gap-1 text-sm flex-wrap">
|
||||||
<button
|
<button
|
||||||
onClick={() => onNavigate('/')}
|
onClick={() => onNavigate(basePath)}
|
||||||
className="flex items-center gap-1 text-duck-forest hover:text-duck-teal transition-colors cursor-pointer font-medium"
|
className="flex items-center gap-1 text-duck-forest hover:text-duck-teal transition-colors cursor-pointer font-medium"
|
||||||
>
|
>
|
||||||
<Home className="h-4 w-4" />
|
<Home className="h-4 w-4" />
|
||||||
<span>home</span>
|
<span>{basePath === '/' ? 'home' : basePath.split('/').pop()}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{segments.map((segment, i) => {
|
{segments.map((segment, i) => {
|
||||||
const segmentPath = '/' + segments.slice(0, i + 1).join('/');
|
const relative = '/' + segments.slice(0, i + 1).join('/');
|
||||||
|
const segmentPath = basePath === '/' ? relative : basePath + relative;
|
||||||
const isLast = i === segments.length - 1;
|
const isLast = i === segments.length - 1;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export type TerminalViewProps = {
|
|||||||
wsPath?: string;
|
wsPath?: string;
|
||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
sandboxed?: boolean;
|
sandboxed?: boolean;
|
||||||
|
cwd?: string;
|
||||||
fontSize?: number;
|
fontSize?: number;
|
||||||
fontFamily?: string;
|
fontFamily?: string;
|
||||||
theme?: TerminalTheme;
|
theme?: TerminalTheme;
|
||||||
@@ -34,13 +35,14 @@ const DEFAULT_THEME: Required<TerminalTheme> = {
|
|||||||
selectionBackground: '#3a3a5e',
|
selectionBackground: '#3a3a5e',
|
||||||
};
|
};
|
||||||
|
|
||||||
const buildWsUrl = (wsPath: string, sessionId?: string, sandboxed?: boolean) => {
|
const buildWsUrl = (wsPath: string, sessionId?: string, sandboxed?: boolean, cwd?: string) => {
|
||||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
const token = localStorage.getItem('BEARER_TOKEN') ?? '';
|
const token = localStorage.getItem('BEARER_TOKEN') ?? '';
|
||||||
const separator = wsPath.includes('?') ? '&' : '?';
|
const separator = wsPath.includes('?') ? '&' : '?';
|
||||||
let url = `${protocol}//${window.location.host}${wsPath}${separator}token=${encodeURIComponent(token)}`;
|
let url = `${protocol}//${window.location.host}${wsPath}${separator}token=${encodeURIComponent(token)}`;
|
||||||
if (sessionId) url += `&sessionId=${encodeURIComponent(sessionId)}`;
|
if (sessionId) url += `&sessionId=${encodeURIComponent(sessionId)}`;
|
||||||
if (sandboxed === false) url += '&sandboxed=false';
|
if (sandboxed === false) url += '&sandboxed=false';
|
||||||
|
if (cwd) url += `&cwd=${encodeURIComponent(cwd)}`;
|
||||||
return url;
|
return url;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -50,6 +52,7 @@ export const TerminalView = ({
|
|||||||
wsPath = '/api/terminal/ws',
|
wsPath = '/api/terminal/ws',
|
||||||
sessionId,
|
sessionId,
|
||||||
sandboxed = true,
|
sandboxed = true,
|
||||||
|
cwd,
|
||||||
fontSize = 14,
|
fontSize = 14,
|
||||||
fontFamily = 'Menlo, Monaco, "Courier New", monospace',
|
fontFamily = 'Menlo, Monaco, "Courier New", monospace',
|
||||||
theme,
|
theme,
|
||||||
@@ -99,6 +102,13 @@ export const TerminalView = ({
|
|||||||
const fitAddon = new FitAddon();
|
const fitAddon = new FitAddon();
|
||||||
term.loadAddon(fitAddon);
|
term.loadAddon(fitAddon);
|
||||||
term.open(container);
|
term.open(container);
|
||||||
|
|
||||||
|
const viewport = container.querySelector('.xterm-viewport') as HTMLElement | null;
|
||||||
|
if (viewport) {
|
||||||
|
viewport.style.scrollbarWidth = 'none';
|
||||||
|
viewport.style.overflow = 'hidden';
|
||||||
|
}
|
||||||
|
|
||||||
fitAddon.fit();
|
fitAddon.fit();
|
||||||
if (autoFocus) term.focus();
|
if (autoFocus) term.focus();
|
||||||
|
|
||||||
@@ -106,7 +116,7 @@ export const TerminalView = ({
|
|||||||
fitAddonRef.current = fitAddon;
|
fitAddonRef.current = fitAddon;
|
||||||
onReadyRef.current?.(term);
|
onReadyRef.current?.(term);
|
||||||
|
|
||||||
const ws = new WebSocket(buildWsUrl(wsPath, sessionId, sandboxed));
|
const ws = new WebSocket(buildWsUrl(wsPath, sessionId, sandboxed, cwd));
|
||||||
wsRef.current = ws;
|
wsRef.current = ws;
|
||||||
|
|
||||||
const handleOpen = () => {
|
const handleOpen = () => {
|
||||||
@@ -180,6 +190,7 @@ export const TerminalView = ({
|
|||||||
wsPath,
|
wsPath,
|
||||||
sessionId,
|
sessionId,
|
||||||
sandboxed,
|
sandboxed,
|
||||||
|
cwd,
|
||||||
fontSize,
|
fontSize,
|
||||||
fontFamily,
|
fontFamily,
|
||||||
background,
|
background,
|
||||||
@@ -190,10 +201,8 @@ export const TerminalView = ({
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className={className} style={{ backgroundColor: background, overflow: 'hidden', ...style }}>
|
||||||
ref={containerRef}
|
<div ref={containerRef} style={{ width: '100%', height: '100%' }} />
|
||||||
className={className}
|
</div>
|
||||||
style={{ backgroundColor: background, ...style }}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user