diff --git a/src/apps/officer-web/Screens/Dashboard/Files/Screen/FileGrid.tsx b/src/apps/officer-web/Screens/Dashboard/Files/Screen/FileGrid.tsx index d02395d1..d547fb45 100644 --- a/src/apps/officer-web/Screens/Dashboard/Files/Screen/FileGrid.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Files/Screen/FileGrid.tsx @@ -23,6 +23,7 @@ type FileGridProps = { onRenamingChange: (name: string | null) => void; getMatchingTasks: (fileName: string, entryType: 'file' | 'directory') => TaskSummary[]; onRunTask: (task: TaskSummary, entry: DirEntry) => void; + onCreateWorkspace: (entry: DirEntry) => void; scrollRef: React.RefObject; }; @@ -72,6 +73,7 @@ export const FileGrid = ({ onRenamingChange, getMatchingTasks, onRunTask, + onCreateWorkspace, scrollRef, }: FileGridProps) => { const lastClickedIdx = useRef(-1); @@ -153,6 +155,7 @@ export const FileGrid = ({ onRenamingChange={onRenamingChange} matchingTasks={getMatchingTasks(entry.name, entry.type)} onRunTask={onRunTask} + onCreateWorkspace={onCreateWorkspace} /> ); diff --git a/src/apps/officer-web/Screens/Dashboard/Files/Screen/FileItem.tsx b/src/apps/officer-web/Screens/Dashboard/Files/Screen/FileItem.tsx index f87577d1..3d3abfee 100644 --- a/src/apps/officer-web/Screens/Dashboard/Files/Screen/FileItem.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Files/Screen/FileItem.tsx @@ -1,5 +1,5 @@ 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 { DropdownMenu, @@ -43,6 +43,7 @@ export type FileItemProps = { onRenamingChange: (name: string | null) => void; matchingTasks: TaskSummary[]; onRunTask: (task: TaskSummary, entry: DirEntry) => void; + onCreateWorkspace: (entry: DirEntry) => void; }; function formatSize(bytes: number): string { @@ -70,6 +71,7 @@ type MenuItemsProps = { onCopy: () => void; matchingTasks: TaskSummary[]; onRunTask: (task: TaskSummary, entry: DirEntry) => void; + onCreateWorkspace: (e: DirEntry) => void; }; const DropdownMenuItems = ({ @@ -83,6 +85,7 @@ const DropdownMenuItems = ({ onCopy, matchingTasks, onRunTask, + onCreateWorkspace, }: MenuItemsProps) => ( <> onChat(entry)} className="cursor-pointer"> @@ -108,6 +111,15 @@ const DropdownMenuItems = ({ )} + {entry.type === 'directory' && ( + <> + + onCreateWorkspace(entry)} className="cursor-pointer"> + + Create Workspace here + + + )} @@ -142,6 +154,7 @@ const ContextMenuItems = ({ onCopy, matchingTasks, onRunTask, + onCreateWorkspace, }: MenuItemsProps) => ( <> onChat(entry)} className="cursor-pointer"> @@ -167,6 +180,15 @@ const ContextMenuItems = ({ )} + {entry.type === 'directory' && ( + <> + + onCreateWorkspace(entry)} className="cursor-pointer"> + + Create Workspace here + + + )} @@ -300,6 +322,7 @@ export const FileItem = ({ onRenamingChange, matchingTasks, onRunTask, + onCreateWorkspace, }: FileItemProps) => { const [renaming, setRenaming] = useState(false); const clickTimer = useRef | null>(null); @@ -376,6 +399,7 @@ export const FileItem = ({ onCopy, matchingTasks, onRunTask, + onCreateWorkspace, }; const cutOpacity = isCut ? 'opacity-50' : ''; diff --git a/src/apps/officer-web/Screens/Dashboard/Files/Screen/index.tsx b/src/apps/officer-web/Screens/Dashboard/Files/Screen/index.tsx index c1fb2059..45293c81 100644 --- a/src/apps/officer-web/Screens/Dashboard/Files/Screen/index.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Files/Screen/index.tsx @@ -33,7 +33,11 @@ type ClipboardState = { paths: string[]; mode: 'copy' | 'cut' } | null; type HomeRoot = 'home' | '~' | 'officer.dev'; -export const Files = () => { +type FilesProps = { + basePath?: string; +}; + +export const Files = ({ basePath = '/' }: FilesProps) => { const { user } = useAuth(); const navigate = useNavigate(); const [searchParams, setSearchParams] = useSearchParams(); @@ -86,6 +90,10 @@ export const Files = () => { }, []); useEffect(() => { + if (basePath !== '/' && !currentPath.startsWith(basePath)) { + setCurrentPath(basePath); + return; + } refresh(); }, [currentPath, homeRoot]); @@ -137,6 +145,10 @@ export const Files = () => { const selectedPaths = () => Array.from(selected).map(entryPath); const handleNavigate = (path: string) => { + if (basePath !== '/' && !path.startsWith(basePath)) { + setCurrentPath(basePath); + return; + } setCurrentPath(path); }; @@ -262,6 +274,18 @@ export const Files = () => { 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 url = cloneUrl.trim(); if (!url) return; @@ -509,7 +533,7 @@ export const Files = () => { {/* Home dir selector (Super Admin only) */} - {user?.role === 'Super Admin' && ( + {basePath === '/' && user?.role === 'Super Admin' && (
{ {/* Breadcrumb */}
- +
{/* Upload progress */} @@ -625,6 +649,7 @@ export const Files = () => { onRenamingChange={setRenamingName} getMatchingTasks={getMatchingTasks} onRunTask={handleRunTask} + onCreateWorkspace={handleCreateWorkspace} scrollRef={fileScrollRef} /> )} @@ -645,6 +670,10 @@ export const Files = () => { New folder + + + Create Workspace here + )} diff --git a/src/apps/officer-web/Screens/Dashboard/Workspaces/WorkspaceListApp.tsx b/src/apps/officer-web/Screens/Dashboard/Workspaces/WorkspaceListApp.tsx index 6f583ad3..b3667af7 100644 --- a/src/apps/officer-web/Screens/Dashboard/Workspaces/WorkspaceListApp.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Workspaces/WorkspaceListApp.tsx @@ -5,8 +5,19 @@ import { useQueryClient } from '@tanstack/react-query'; import { useGlobal } from 'hooks/useGlobal'; import { useClient } from 'hooks/useClient'; import { generateSlug } from 'helpers/slug'; +import { useUserState } from '@/state/useUserState'; import { useWorkspacesState } from '@/state/useWorkspacesState'; import type { WorkspaceDefinition } from '@/components/Workspace'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; import { SELECTED_WORKSPACE_KEY, CREATING_WORKSPACE_KEY, @@ -28,8 +39,10 @@ export const WorkspaceListApp = () => { const [, setName] = useGlobal(NEW_WS_NAME_KEY, ''); const [, setDescription] = useGlobal(NEW_WS_DESC_KEY, ''); const [, setTemplateIdx] = useGlobal(NEW_WS_TEMPLATE_KEY, 0); + const [, setFilePath] = useUserState('files/currentPath', '/'); const [search, setSearch] = useState(''); + const [deleting, setDeleting] = useState(null); const isWorkspacesPage = location.pathname === '/workspaces'; const filtered = search ? workspaces.filter((ws) => { @@ -46,18 +59,26 @@ export const WorkspaceListApp = () => { setName(ws.name); setDescription(ws.description ?? ''); setTemplateIdx(ws.templateIdx ?? 0); + const cwdPath = !ws.cwd || ws.cwd === '~' ? '/' : ws.cwd.replace(/^~\//, '/'); + setFilePath(cwdPath); }; const handleDelete = (ev: React.MouseEvent, ws: WorkspaceDefinition) => { ev.stopPropagation(); - setWorkspaces((prev) => prev.filter((w) => w.id !== ws.id)); - if (selected === ws.id) setSelected(null); + setDeleting(ws); + }; - 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>(['WORKSPACES_STATE']) ?? {}; const { [layoutKey]: _, ...rest } = currentState; queryClient.setQueryData(['WORKSPACES_STATE'], rest); client.patch('/user/workspaces-state', { [layoutKey]: null }).catch(() => {}); + setDeleting(null); }; const handleClick = (ws: WorkspaceDefinition) => { @@ -88,6 +109,7 @@ export const WorkspaceListApp = () => { setName(generateSlug()); setDescription(''); setTemplateIdx(0); + setFilePath('/'); setCreating(true); if (!isWorkspacesPage) navigate('/workspaces'); }} @@ -146,6 +168,23 @@ export const WorkspaceListApp = () => {

)}
+ + { if (!open) setDeleting(null); }}> + + + Delete workspace + + Are you sure you want to delete {deleting?.name}? This action cannot be undone. + + + + Cancel + + Delete + + + + ); }; diff --git a/src/apps/officer-web/Screens/Dashboard/Workspaces/WorkspaceListScreen.tsx b/src/apps/officer-web/Screens/Dashboard/Workspaces/WorkspaceListScreen.tsx index cf154799..5d764591 100644 --- a/src/apps/officer-web/Screens/Dashboard/Workspaces/WorkspaceListScreen.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Workspaces/WorkspaceListScreen.tsx @@ -1,5 +1,6 @@ +import { useEffect, useState } from 'react'; 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 { useGlobal } from 'hooks/useGlobal'; import { useClient } from 'hooks/useClient'; @@ -9,6 +10,7 @@ import { WorkspaceLayout, WorkspaceView, createDefaultLayout } from '@/component import type { LayoutNode, WorkspaceDefinition } from '@/components/Workspace'; import { Button } from '@/components/ui/button'; import { generateSlug, slugify } from 'helpers/slug'; +import { Files } from '../Files'; 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'; @@ -389,6 +391,14 @@ const CreatePanel = () => { ); }; +// --- CwdFileBrowser (basePath-constrained file browser for workspace form) --- + +const CwdFileBrowser = () => { + const [currentPath] = useUserState('files/currentPath', '/'); + const [basePath] = useState(currentPath); + return ; +}; + // --- New Workspace Layout --- const newWsLayout: LayoutNode = { @@ -403,7 +413,7 @@ const newWsLayout: LayoutNode = { direction: 'horizontal', children: [ { 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, @@ -425,6 +435,7 @@ const newWsLayout: LayoutNode = { const newWsRegistry = { ...appRegistry, + 'file-browser-cwd': { name: 'File Browser', icon: FolderOpen, component: CwdFileBrowser }, 'new-ws-name': { name: 'Name', icon: Type, component: NamePanel }, 'new-ws-template': { name: 'Template', icon: Layout, component: TemplatePanel }, 'new-ws-create': { name: 'Create', icon: Rocket, component: CreatePanel }, @@ -440,6 +451,7 @@ const WorkspacePreviewEmpty = () => { const [, setName] = useGlobal(NEW_WS_NAME_KEY, ''); const [, setDescription] = useGlobal(NEW_WS_DESC_KEY, ''); const [, setTemplateIdx] = useGlobal(NEW_WS_TEMPLATE_KEY, 0); + const [, setFilePath] = useUserState('files/currentPath', '/'); if (creating || editingId) return ; @@ -447,6 +459,7 @@ const WorkspacePreviewEmpty = () => { setName(generateSlug()); setDescription(''); setTemplateIdx(0); + setFilePath('/'); setCreating(true); }; @@ -505,6 +518,7 @@ export const WorkspaceListScreen = () => { }; export const NewWorkspaceRedirect = () => { + const navigate = useNavigate(); const [params] = useSearchParams(); const [, setName] = useGlobal(NEW_WS_NAME_KEY, ''); const [, setDescription] = useGlobal(NEW_WS_DESC_KEY, ''); @@ -514,16 +528,16 @@ export const NewWorkspaceRedirect = () => { const [, setSelected] = useGlobal(SELECTED_WORKSPACE_KEY, null); const [, setFilePath] = useUserState('files/currentPath', '/'); - const name = params.get('name'); - const cwd = params.get('cwd'); + useEffect(() => { + setSelected(null); + setEditing(null); + setName(params.get('name') || generateSlug()); + setDescription(''); + setTemplateIdx(0); + setFilePath(params.get('cwd') || '/'); + setCreating(true); + navigate('/workspaces', { replace: true }); + }, []); - setSelected(null); - setEditing(null); - setName(name || generateSlug()); - setDescription(''); - setTemplateIdx(0); - setCreating(true); - if (cwd) setFilePath(cwd); - - return ; + return null; }; diff --git a/src/apps/officer-web/Screens/Dashboard/Workspaces/app-registry.tsx b/src/apps/officer-web/Screens/Dashboard/Workspaces/app-registry.tsx index 291e32ba..5b9fc8bc 100644 --- a/src/apps/officer-web/Screens/Dashboard/Workspaces/app-registry.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Workspaces/app-registry.tsx @@ -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 { useAuth } from 'hooks/useAuth'; import type { AppRegistry } from '@/components/Workspace'; @@ -48,12 +48,24 @@ const PiMonoChatWidget = ({ onProviderChange }: { onProviderChange: (p: 'claude' return ; }; +const cwdToPath = (cwd: string) => (cwd === '~' ? '/' : cwd.slice(1)); + +const FileBrowserWrapper = () => { + const { cwd } = useWorkspace(); + const basePath = cwdToPath(cwd); + return ; +}; + const CodeEditorWrapper = () => ; +const EMPTY_TERMINALS: Record = {}; + const TerminalWrapper = ({ panelId }: { panelId: string }) => { - const { workspaceId } = useWorkspace(); + const { workspaceId, cwd } = useWorkspace(); const stateKey = workspaceId ? `ws-terminals-${workspaceId}` : 'ws-terminals-default'; - const [terminals, setTerminals] = useWorkspacesState>(stateKey, {}); + const [terminals, setTerminals] = useWorkspacesState>(stateKey, EMPTY_TERMINALS); + const setTerminalsRef = useRef(setTerminals); + setTerminalsRef.current = setTerminals; const sessionId = terminals[panelId]; @@ -63,16 +75,27 @@ const TerminalWrapper = ({ panelId }: { panelId: string }) => { } }, [panelId, sessionId, setTerminals]); + useEffect(() => { + return () => { + setTerminalsRef.current((prev) => { + const { [panelId]: _, ...rest } = prev; + return rest; + }); + }; + }, [panelId]); + if (!sessionId) return null; - return ; + return ; }; const HostTerminalWrapper = ({ panelId }: { panelId: string }) => { const { user } = useAuth(); - const { workspaceId } = useWorkspace(); + const { workspaceId, cwd } = useWorkspace(); const stateKey = workspaceId ? `ws-host-terminals-${workspaceId}` : 'ws-host-terminals-default'; - const [terminals, setTerminals] = useWorkspacesState>(stateKey, {}); + const [terminals, setTerminals] = useWorkspacesState>(stateKey, EMPTY_TERMINALS); + const setTerminalsRef = useRef(setTerminals); + setTerminalsRef.current = setTerminals; const sessionId = terminals[panelId]; @@ -82,6 +105,15 @@ const HostTerminalWrapper = ({ panelId }: { panelId: string }) => { } }, [panelId, sessionId, setTerminals]); + useEffect(() => { + return () => { + setTerminalsRef.current((prev) => { + const { [panelId]: _, ...rest } = prev; + return rest; + }); + }; + }, [panelId]); + if (user?.role !== 'Super Admin') { return (
@@ -92,12 +124,12 @@ const HostTerminalWrapper = ({ panelId }: { panelId: string }) => { if (!sessionId) return null; - return ; + return ; }; export const appRegistry: AppRegistry = { 'chat': { name: 'Chat', icon: MessageSquare, component: ChatWidget }, - 'file-browser': { name: 'File Browser', icon: FolderOpen, component: () => }, + 'file-browser': { name: 'File Browser', icon: FolderOpen, component: FileBrowserWrapper }, 'chat-history': { name: 'Chat History', icon: History, component: () => }, 'sound-library': { name: 'Sound Library', icon: Music, component: () => }, 'code-editor': { name: 'Code Editor', icon: Code, component: CodeEditorWrapper }, diff --git a/src/server.tsx b/src/server.tsx index 2b0a7da4..2e9e066d 100644 --- a/src/server.tsx +++ b/src/server.tsx @@ -20,6 +20,7 @@ type WSData = { provider: 'claude' | 'opencode' | 'pi-mono' | 'terminal'; sandboxed: boolean; sessionId?: string; + cwd?: string; }; const handlers: Record = { @@ -47,8 +48,9 @@ async function upgradeWs(req: Request, server: any, provider: 'claude' | 'openco const url = new URL(req.url); const sessionId = url.searchParams.get('sessionId') ?? undefined; const sandboxed = url.searchParams.get('sandboxed') !== 'false'; + const cwd = url.searchParams.get('cwd') ?? undefined; 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 }); } catch { diff --git a/src/servers/api/terminal/Dockerfile.terminal-sidecar b/src/servers/api/terminal/Dockerfile.terminal-sidecar index 6cb836ac..f3e417dd 100644 --- a/src/servers/api/terminal/Dockerfile.terminal-sidecar +++ b/src/servers/api/terminal/Dockerfile.terminal-sidecar @@ -1,12 +1,25 @@ FROM imbios/bun-node:22-slim RUN apt-get update \ - && apt-get install -y python3 make g++ zsh git curl ca-certificates fortune-mod cowsay \ - && rm -rf /var/lib/apt/lists/* + && apt-get install -y \ + 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 COPY pty-sidecar.mjs /app/pty-sidecar.mjs +COPY entrypoint.sh /app/entrypoint.sh COPY templates /opt/terminal-templates 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 \ && rm -rf /tmp/eza.tar.gz /tmp/completions /tmp/man +RUN mkdir -p /home/officer WORKDIR /home/officer @@ -32,4 +46,4 @@ ENV PATH="/usr/games:${PATH}" EXPOSE 5337 -CMD ["node", "/app/pty-sidecar.mjs"] +ENTRYPOINT ["/app/entrypoint.sh"] diff --git a/src/servers/api/terminal/entrypoint.sh b/src/servers/api/terminal/entrypoint.sh new file mode 100755 index 00000000..6657c314 --- /dev/null +++ b/src/servers/api/terminal/entrypoint.sh @@ -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 diff --git a/src/servers/api/terminal/pty-sidecar.mjs b/src/servers/api/terminal/pty-sidecar.mjs index 6fbea184..d1a206b3 100644 --- a/src/servers/api/terminal/pty-sidecar.mjs +++ b/src/servers/api/terminal/pty-sidecar.mjs @@ -45,6 +45,11 @@ const ensureUserFiles = async (homeDir) => { 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'); if (!existsSync(starshipPath)) { await cp(join(templateDir, 'starship-officer.toml'), starshipPath); diff --git a/src/servers/api/terminal/templates/.tmux.conf b/src/servers/api/terminal/templates/.tmux.conf new file mode 100644 index 00000000..ccf66cbe --- /dev/null +++ b/src/servers/api/terminal/templates/.tmux.conf @@ -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@' diff --git a/src/servers/api/terminal/websocket.ts b/src/servers/api/terminal/websocket.ts index ed78c5bc..cf6b7eb9 100644 --- a/src/servers/api/terminal/websocket.ts +++ b/src/servers/api/terminal/websocket.ts @@ -5,13 +5,14 @@ import { fileURLToPath } from 'node:url'; import { getHomeDir } from '@@/data-path'; 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 BridgeSession = { client: ServerWebSocket; sidecar: WebSocket | null; dockerId: string; port: number; + pendingMessages: string[]; }; type ContainerInfo = { @@ -99,7 +100,7 @@ const ensureDockerImage = () => { 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(); const dockerPath = Bun.which('docker') ?? 'docker'; 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' }); } - let userArgs: string[] = []; + const username = (email.split('@')[0] ?? 'officer').replace(/[^a-z0-9_-]/g, '_').slice(0, 32); + let uid = 1000; + let gid = 1000; try { const stats = statSync(homeDir); - userArgs = ['--user', `${stats.uid}:${stats.gid}`]; + uid = stats.uid; + gid = stats.gid; } catch { - userArgs = []; + // fallback to defaults } const run = Bun.spawnSync({ @@ -127,13 +131,18 @@ const startDockerSidecar = (port: number, homeDir: string, userId: number): { do dockerId, '--restart', 'unless-stopped', - ...userArgs, '-p', `127.0.0.1:${port}:${port}`, '-e', `TERMINAL_PTY_PORT=${port}`, '-e', 'TERMINAL_PTY_HOST=0.0.0.0', + '-e', + `TERMINAL_USER=${username}`, + '-e', + `TERMINAL_UID=${uid}`, + '-e', + `TERMINAL_GID=${gid}`, '-v', `${homeDir}:/home/officer`, '-w', @@ -211,7 +220,7 @@ const ensureDockerContainer = async (email: string, userId: number, homeDir: str } 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 }; map[email] = next; await saveContainerMap(map); @@ -268,6 +277,12 @@ export const initTerminalSidecars = async () => { const containerShell: ShellInfo = { command: '/bin/zsh', args: ['-d', '-i'], name: 'zsh' }; 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 = { async open(ws: ServerWebSocket) { const { email, role, sandboxed } = ws.data; @@ -278,16 +293,20 @@ export const terminalWebsocket = { } if (!sandboxed) { + const session: BridgeSession = { client: ws, sidecar: null, dockerId: '', port: HOST_SIDECAR_PORT, pendingMessages: [] }; + sessions.set(ws, session); + let sidecar: WebSocket | null = null; try { sidecar = await connectSidecar(HOST_SIDECAR_PORT); } catch (err) { const message = err instanceof Error ? err.message : 'Failed to connect host sidecar'; sendOutput(ws, `\r\n[Terminal error] ${message}\r\n`); + sessions.delete(ws); return; } - sessions.set(ws, { client: ws, sidecar, dockerId: '', port: HOST_SIDECAR_PORT }); + session.sidecar = sidecar; sidecar.addEventListener('message', (ev) => { try { @@ -306,11 +325,14 @@ export const terminalWebsocket = { type: 'init', sessionId: ws.data.sessionId ?? `host-${ws.data.userId}`, 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, userLabel: email, }), ); + + for (const msg of session.pendingMessages) sidecar.send(msg); + session.pendingMessages = []; return; } @@ -319,6 +341,9 @@ export const terminalWebsocket = { mkdirSync(userRoot, { 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 info: ContainerInfo | undefined; try { @@ -335,10 +360,13 @@ export const terminalWebsocket = { } sendOutput(ws, '\r\n[Process exited]\r\n'); if (info) stopDockerSidecar(info.dockerId); + sessions.delete(ws); 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) => { try { @@ -357,19 +385,28 @@ export const terminalWebsocket = { type: 'init', sessionId: ws.data.sessionId ?? `default-${ws.data.userId}`, shell: containerShell, - cwd: containerHome, + cwd: resolveCwd(containerHome, ws.data.cwd), homeDir: containerHome, userLabel: email, }), ); + + for (const msg of session.pendingMessages) sidecar.send(msg); + session.pendingMessages = []; }, message(ws: ServerWebSocket, raw: string | Buffer) { 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 { - const payload = typeof raw === 'string' ? raw : raw.toString(); session.sidecar.send(payload); } catch { // ignore diff --git a/src/workspaces/apps/FileBrowser/Breadcrumb.tsx b/src/workspaces/apps/FileBrowser/Breadcrumb.tsx index c80ba39b..be85161c 100644 --- a/src/workspaces/apps/FileBrowser/Breadcrumb.tsx +++ b/src/workspaces/apps/FileBrowser/Breadcrumb.tsx @@ -3,23 +3,26 @@ import { ChevronRight, Home } from 'lucide-react'; type BreadcrumbProps = { path: string; onNavigate: (path: string) => void; + basePath?: string; }; -export const Breadcrumb = ({ path, onNavigate }: BreadcrumbProps) => { - const segments = path.split('/').filter(Boolean); +export const Breadcrumb = ({ path, onNavigate, basePath = '/' }: BreadcrumbProps) => { + const relativePath = basePath !== '/' && path.startsWith(basePath) ? path.slice(basePath.length) : path; + const segments = relativePath.split('/').filter(Boolean); return (