Workspaces and Terminals

This commit is contained in:
2026-02-19 18:01:08 +00:00
parent 9870fa7ae8
commit 4733a972dd
14 changed files with 402 additions and 57 deletions
@@ -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<HTMLDivElement | null>;
};
@@ -72,6 +73,7 @@ export const FileGrid = ({
onRenamingChange,
getMatchingTasks,
onRunTask,
onCreateWorkspace,
scrollRef,
}: FileGridProps) => {
const lastClickedIdx = useRef<number>(-1);
@@ -153,6 +155,7 @@ export const FileGrid = ({
onRenamingChange={onRenamingChange}
matchingTasks={getMatchingTasks(entry.name, entry.type)}
onRunTask={onRunTask}
onCreateWorkspace={onCreateWorkspace}
/>
);
@@ -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) => (
<>
<DropdownMenuItem onClick={() => onChat(entry)} className="cursor-pointer">
@@ -108,6 +111,15 @@ const DropdownMenuItems = ({
</DropdownMenuSubContent>
</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 />
<DropdownMenuItem onClick={onCut} className="cursor-pointer">
<Scissors className="mr-2 h-4 w-4" />
@@ -142,6 +154,7 @@ const ContextMenuItems = ({
onCopy,
matchingTasks,
onRunTask,
onCreateWorkspace,
}: MenuItemsProps) => (
<>
<ContextMenuItem onClick={() => onChat(entry)} className="cursor-pointer">
@@ -167,6 +180,15 @@ const ContextMenuItems = ({
</ContextMenuSubContent>
</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 />
<ContextMenuItem onClick={onCut} className="cursor-pointer">
<Scissors className="mr-2 h-4 w-4" />
@@ -300,6 +322,7 @@ export const FileItem = ({
onRenamingChange,
matchingTasks,
onRunTask,
onCreateWorkspace,
}: FileItemProps) => {
const [renaming, setRenaming] = useState(false);
const clickTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -376,6 +399,7 @@ export const FileItem = ({
onCopy,
matchingTasks,
onRunTask,
onCreateWorkspace,
};
const cutOpacity = isCut ? 'opacity-50' : '';
@@ -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 = () => {
</div>
{/* 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">
<RadioGroup
value={homeRoot}
@@ -539,7 +563,7 @@ export const Files = () => {
{/* Breadcrumb */}
<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>
{/* 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 = () => {
<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>
</ContextMenuContent>
</ContextMenu>
)}
@@ -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<string>(NEW_WS_NAME_KEY, '');
const [, setDescription] = useGlobal<string>(NEW_WS_DESC_KEY, '');
const [, setTemplateIdx] = useGlobal<number>(NEW_WS_TEMPLATE_KEY, 0);
const [, setFilePath] = useUserState<string>('files/currentPath', '/');
const [search, setSearch] = useState('');
const [deleting, setDeleting] = useState<WorkspaceDefinition | null>(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<Record<string, unknown>>(['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 = () => {
</p>
)}
</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>
);
};
@@ -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<string>('files/currentPath', '/');
const [basePath] = useState(currentPath);
return <Files basePath={basePath} />;
};
// --- 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<string>(NEW_WS_NAME_KEY, '');
const [, setDescription] = useGlobal<string>(NEW_WS_DESC_KEY, '');
const [, setTemplateIdx] = useGlobal<number>(NEW_WS_TEMPLATE_KEY, 0);
const [, setFilePath] = useUserState<string>('files/currentPath', '/');
if (creating || editingId) return <NewWorkspaceForm />;
@@ -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<string>(NEW_WS_NAME_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 [, setFilePath] = useUserState<string>('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 <Navigate to="/workspaces" replace />;
return null;
};
@@ -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';
@@ -39,12 +39,24 @@ const OpenCodeChatWidget = ({ onProviderChange }: { onProviderChange: (p: 'claud
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 EMPTY_TERMINALS: Record<string, string> = {};
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<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];
@@ -54,16 +66,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 <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 { user } = useAuth();
const { workspaceId } = useWorkspace();
const { workspaceId, cwd } = useWorkspace();
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];
@@ -73,6 +96,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 (
<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;
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 = {
'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 /> },
'sound-library': { name: 'Sound Library', icon: Music, component: () => <Catalog /> },
'code-editor': { name: 'Code Editor', icon: Code, component: CodeEditorWrapper },
+3 -1
View File
@@ -19,6 +19,7 @@ type WSData = {
provider: 'claude' | 'opencode' | 'terminal';
sandboxed: boolean;
sessionId?: string;
cwd?: string;
};
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 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 {
@@ -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"]
+34
View File
@@ -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
+5
View File
@@ -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);
@@ -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@'
+50 -13
View File
@@ -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<WSData>;
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<WSData>) {
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<WSData>, 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
@@ -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 (
<nav className="flex items-center gap-1 text-sm flex-wrap">
<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"
>
<Home className="h-4 w-4" />
<span>home</span>
<span>{basePath === '/' ? 'home' : basePath.split('/').pop()}</span>
</button>
{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;
return (
+16 -7
View File
@@ -18,6 +18,7 @@ export type TerminalViewProps = {
wsPath?: string;
sessionId?: string;
sandboxed?: boolean;
cwd?: string;
fontSize?: number;
fontFamily?: string;
theme?: TerminalTheme;
@@ -34,13 +35,14 @@ const DEFAULT_THEME: Required<TerminalTheme> = {
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 token = localStorage.getItem('BEARER_TOKEN') ?? '';
const separator = wsPath.includes('?') ? '&' : '?';
let url = `${protocol}//${window.location.host}${wsPath}${separator}token=${encodeURIComponent(token)}`;
if (sessionId) url += `&sessionId=${encodeURIComponent(sessionId)}`;
if (sandboxed === false) url += '&sandboxed=false';
if (cwd) url += `&cwd=${encodeURIComponent(cwd)}`;
return url;
};
@@ -50,6 +52,7 @@ export const TerminalView = ({
wsPath = '/api/terminal/ws',
sessionId,
sandboxed = true,
cwd,
fontSize = 14,
fontFamily = 'Menlo, Monaco, "Courier New", monospace',
theme,
@@ -99,6 +102,13 @@ export const TerminalView = ({
const fitAddon = new FitAddon();
term.loadAddon(fitAddon);
term.open(container);
const viewport = container.querySelector('.xterm-viewport') as HTMLElement | null;
if (viewport) {
viewport.style.scrollbarWidth = 'none';
viewport.style.overflow = 'hidden';
}
fitAddon.fit();
if (autoFocus) term.focus();
@@ -106,7 +116,7 @@ export const TerminalView = ({
fitAddonRef.current = fitAddon;
onReadyRef.current?.(term);
const ws = new WebSocket(buildWsUrl(wsPath, sessionId, sandboxed));
const ws = new WebSocket(buildWsUrl(wsPath, sessionId, sandboxed, cwd));
wsRef.current = ws;
const handleOpen = () => {
@@ -180,6 +190,7 @@ export const TerminalView = ({
wsPath,
sessionId,
sandboxed,
cwd,
fontSize,
fontFamily,
background,
@@ -190,10 +201,8 @@ export const TerminalView = ({
]);
return (
<div
ref={containerRef}
className={className}
style={{ backgroundColor: background, ...style }}
/>
<div className={className} style={{ backgroundColor: background, overflow: 'hidden', ...style }}>
<div ref={containerRef} style={{ width: '100%', height: '100%' }} />
</div>
);
};