feat: canonical workspace tab IDs with URL routing and draft agent tabs
Rewrite workspace tab management to use stable tab IDs (agent_, terminal_, draft_, file_) with dedicated /tab/[tabId] routes, replace separate file-tabs store with unified workspace-tabs store, support multiple concurrent draft agent tabs, and detect text files by content instead of extension allowlist. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
97f0f4b266
commit
73eabd2eea
@@ -48,6 +48,7 @@ import {
|
|||||||
buildHostDraftRoute,
|
buildHostDraftRoute,
|
||||||
parseHostAgentRouteFromPathname,
|
parseHostAgentRouteFromPathname,
|
||||||
parseHostWorkspaceAgentRouteFromPathname,
|
parseHostWorkspaceAgentRouteFromPathname,
|
||||||
|
parseHostWorkspaceTabRouteFromPathname,
|
||||||
} from "@/utils/host-routes";
|
} from "@/utils/host-routes";
|
||||||
import { getTauri } from "@/utils/tauri";
|
import { getTauri } from "@/utils/tauri";
|
||||||
import { PerfDiagnosticsProvider } from "@/runtime/perf-diagnostics";
|
import { PerfDiagnosticsProvider } from "@/runtime/perf-diagnostics";
|
||||||
@@ -381,6 +382,12 @@ function AppWithSidebar({ children }: { children: ReactNode }) {
|
|||||||
// Parse selectedAgentKey directly from pathname
|
// Parse selectedAgentKey directly from pathname
|
||||||
// useLocalSearchParams doesn't update when navigating between same-pattern routes
|
// useLocalSearchParams doesn't update when navigating between same-pattern routes
|
||||||
const selectedAgentKey = useMemo(() => {
|
const selectedAgentKey = useMemo(() => {
|
||||||
|
const workspaceTab = parseHostWorkspaceTabRouteFromPathname(pathname);
|
||||||
|
if (workspaceTab?.tabId?.startsWith("agent_")) {
|
||||||
|
const agentId = workspaceTab.tabId.slice("agent_".length).trim();
|
||||||
|
return agentId ? `${workspaceTab.serverId}:${agentId}` : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
const match =
|
const match =
|
||||||
parseHostWorkspaceAgentRouteFromPathname(pathname) ??
|
parseHostWorkspaceAgentRouteFromPathname(pathname) ??
|
||||||
parseHostAgentRouteFromPathname(pathname);
|
parseHostAgentRouteFromPathname(pathname);
|
||||||
@@ -470,6 +477,7 @@ export default function RootLayout() {
|
|||||||
<Stack.Screen name="settings" />
|
<Stack.Screen name="settings" />
|
||||||
<Stack.Screen name="h/[serverId]/new" />
|
<Stack.Screen name="h/[serverId]/new" />
|
||||||
<Stack.Screen name="h/[serverId]/workspace/[workspaceId]/index" />
|
<Stack.Screen name="h/[serverId]/workspace/[workspaceId]/index" />
|
||||||
|
<Stack.Screen name="h/[serverId]/workspace/[workspaceId]/tab/[tabId]" />
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="h/[serverId]/workspace/[workspaceId]/agent/[agentId]"
|
name="h/[serverId]/workspace/[workspaceId]/agent/[agentId]"
|
||||||
options={{ gestureEnabled: false }}
|
options={{ gestureEnabled: false }}
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { useLocalSearchParams } from "expo-router";
|
||||||
|
import { WorkspaceScreen } from "@/screens/workspace/workspace-screen";
|
||||||
|
|
||||||
|
export default function HostWorkspaceTabRoute() {
|
||||||
|
const params = useLocalSearchParams<{
|
||||||
|
serverId?: string;
|
||||||
|
workspaceId?: string;
|
||||||
|
tabId?: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const tabId = typeof params.tabId === "string" ? params.tabId : "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<WorkspaceScreen
|
||||||
|
serverId={typeof params.serverId === "string" ? params.serverId : ""}
|
||||||
|
workspaceId={typeof params.workspaceId === "string" ? params.workspaceId : ""}
|
||||||
|
routeTab={null}
|
||||||
|
routeTabId={tabId || null}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useState, type ReactElement } from "react";
|
import { useCallback, useState, type ReactElement } from "react";
|
||||||
import {
|
import {
|
||||||
DndContext,
|
DndContext,
|
||||||
closestCenter,
|
closestCenter,
|
||||||
@@ -118,14 +118,8 @@ export function SortableInlineList<T>({
|
|||||||
onDragBegin?: () => void;
|
onDragBegin?: () => void;
|
||||||
}): ReactElement {
|
}): ReactElement {
|
||||||
const [activeId, setActiveId] = useState<string | null>(null);
|
const [activeId, setActiveId] = useState<string | null>(null);
|
||||||
const [items, setItems] = useState<T[]>(() => data);
|
const [dragItems, setDragItems] = useState<T[] | null>(null);
|
||||||
|
const items = dragItems ?? data;
|
||||||
useEffect(() => {
|
|
||||||
if (activeId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setItems((current) => (current === data ? current : data));
|
|
||||||
}, [activeId, data]);
|
|
||||||
|
|
||||||
const sensors = useSensors(
|
const sensors = useSensors(
|
||||||
useSensor(PointerSensor, {
|
useSensor(PointerSensor, {
|
||||||
@@ -143,10 +137,11 @@ export function SortableInlineList<T>({
|
|||||||
if (disabled) {
|
if (disabled) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
setDragItems(data);
|
||||||
setActiveId(String(event.active.id));
|
setActiveId(String(event.active.id));
|
||||||
onDragBegin?.();
|
onDragBegin?.();
|
||||||
},
|
},
|
||||||
[disabled, onDragBegin]
|
[data, disabled, onDragBegin]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDragEnd = useCallback(
|
const handleDragEnd = useCallback(
|
||||||
@@ -154,6 +149,7 @@ export function SortableInlineList<T>({
|
|||||||
const { active, over } = event;
|
const { active, over } = event;
|
||||||
|
|
||||||
setActiveId(null);
|
setActiveId(null);
|
||||||
|
setDragItems(null);
|
||||||
|
|
||||||
if (disabled) {
|
if (disabled) {
|
||||||
return;
|
return;
|
||||||
@@ -169,10 +165,11 @@ export function SortableInlineList<T>({
|
|||||||
|
|
||||||
if (oldIndex >= 0 && newIndex >= 0 && oldIndex !== newIndex) {
|
if (oldIndex >= 0 && newIndex >= 0 && oldIndex !== newIndex) {
|
||||||
const newItems = arrayMove(items, oldIndex, newIndex);
|
const newItems = arrayMove(items, oldIndex, newIndex);
|
||||||
setItems(newItems);
|
setDragItems(newItems);
|
||||||
onDragEnd?.(newItems);
|
onDragEnd?.(newItems);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
},
|
},
|
||||||
[disabled, items, keyExtractor, onDragEnd]
|
[disabled, items, keyExtractor, onDragEnd]
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
parseHostAgentRouteFromPathname,
|
parseHostAgentRouteFromPathname,
|
||||||
parseHostWorkspaceAgentRouteFromPathname,
|
parseHostWorkspaceAgentRouteFromPathname,
|
||||||
parseHostWorkspaceRouteFromPathname,
|
parseHostWorkspaceRouteFromPathname,
|
||||||
|
parseHostWorkspaceTabRouteFromPathname,
|
||||||
} from "@/utils/host-routes";
|
} from "@/utils/host-routes";
|
||||||
|
|
||||||
const DRAFT_AGENT_ID = "__new_agent__";
|
const DRAFT_AGENT_ID = "__new_agent__";
|
||||||
@@ -14,6 +15,19 @@ export function resolveSelectedOrRouteAgentKey(input: {
|
|||||||
if (input.selectedAgentId) {
|
if (input.selectedAgentId) {
|
||||||
return input.selectedAgentId;
|
return input.selectedAgentId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const workspaceTabRoute = parseHostWorkspaceTabRouteFromPathname(input.pathname);
|
||||||
|
if (workspaceTabRoute?.tabId) {
|
||||||
|
const tabId = workspaceTabRoute.tabId;
|
||||||
|
if (tabId.startsWith("agent_")) {
|
||||||
|
const agentId = tabId.slice("agent_".length).trim();
|
||||||
|
return agentId ? `${workspaceTabRoute.serverId}:${agentId}` : null;
|
||||||
|
}
|
||||||
|
if (tabId.startsWith("draft_")) {
|
||||||
|
return `${workspaceTabRoute.serverId}:${DRAFT_AGENT_ID}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const route =
|
const route =
|
||||||
parseHostWorkspaceAgentRouteFromPathname(input.pathname) ??
|
parseHostWorkspaceAgentRouteFromPathname(input.pathname) ??
|
||||||
parseHostAgentRouteFromPathname(input.pathname);
|
parseHostAgentRouteFromPathname(input.pathname);
|
||||||
|
|||||||
@@ -436,16 +436,28 @@ function AgentScreenContent({
|
|||||||
);
|
);
|
||||||
const streamItems = streamItemsRaw ?? EMPTY_STREAM_ITEMS;
|
const streamItems = streamItemsRaw ?? EMPTY_STREAM_ITEMS;
|
||||||
|
|
||||||
const pendingCreate = useCreateFlowStore((state) => state.pending);
|
const pendingByDraftId = useCreateFlowStore((state) => state.pendingByDraftId);
|
||||||
const markPendingCreateLifecycle = useCreateFlowStore(
|
const markPendingCreateLifecycle = useCreateFlowStore(
|
||||||
(state) => state.markLifecycle
|
(state) => state.markLifecycle
|
||||||
);
|
);
|
||||||
const clearPendingCreate = useCreateFlowStore((state) => state.clear);
|
const clearPendingCreate = useCreateFlowStore((state) => state.clear);
|
||||||
const isPendingCreateForRoute =
|
const pendingCreate = useMemo(() => {
|
||||||
Boolean(pendingCreate) &&
|
if (!resolvedAgentId) {
|
||||||
pendingCreate?.lifecycle === "active" &&
|
return null;
|
||||||
pendingCreate?.serverId === serverId &&
|
}
|
||||||
pendingCreate?.agentId === resolvedAgentId;
|
const values = Object.values(pendingByDraftId);
|
||||||
|
for (const entry of values) {
|
||||||
|
if (
|
||||||
|
entry.lifecycle === "active" &&
|
||||||
|
entry.serverId === serverId &&
|
||||||
|
entry.agentId === resolvedAgentId
|
||||||
|
) {
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}, [pendingByDraftId, resolvedAgentId, serverId]);
|
||||||
|
const isPendingCreateForRoute = Boolean(pendingCreate);
|
||||||
|
|
||||||
// Select only the specific initializing state
|
// Select only the specific initializing state
|
||||||
const isInitializingFromMap = useSessionStore((state) =>
|
const isInitializingFromMap = useSessionStore((state) =>
|
||||||
@@ -740,7 +752,7 @@ function AgentScreenContent({
|
|||||||
item.kind === "user_message" &&
|
item.kind === "user_message" &&
|
||||||
item.id === pendingCreate.clientMessageId
|
item.id === pendingCreate.clientMessageId
|
||||||
);
|
);
|
||||||
if (hasUserMessage && canFinalizePendingCreate) {
|
if (hasUserMessage && canFinalizePendingCreate) {
|
||||||
if (
|
if (
|
||||||
resolvedAgentId &&
|
resolvedAgentId &&
|
||||||
pendingCreate.images &&
|
pendingCreate.images &&
|
||||||
@@ -766,8 +778,8 @@ function AgentScreenContent({
|
|||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
markPendingCreateLifecycle("sent");
|
markPendingCreateLifecycle({ draftId: pendingCreate.draftId, lifecycle: "sent" });
|
||||||
clearPendingCreate();
|
clearPendingCreate({ draftId: pendingCreate.draftId });
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
canFinalizePendingCreate,
|
canFinalizePendingCreate,
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ import { AGENT_PROVIDER_DEFINITIONS } from '@server/server/agent/provider-manife
|
|||||||
import { buildHostAgentDetailRoute } from '@/utils/host-routes'
|
import { buildHostAgentDetailRoute } from '@/utils/host-routes'
|
||||||
import { useTauriDragHandlers } from '@/utils/tauri-window'
|
import { useTauriDragHandlers } from '@/utils/tauri-window'
|
||||||
import { useKeyboardShiftStyle } from '@/hooks/use-keyboard-shift-style'
|
import { useKeyboardShiftStyle } from '@/hooks/use-keyboard-shift-style'
|
||||||
|
import { normalizeAgentSnapshot } from '@/utils/agent-snapshots'
|
||||||
|
|
||||||
const DRAFT_AGENT_ID = '__new_agent__'
|
const DRAFT_AGENT_ID = '__new_agent__'
|
||||||
const EMPTY_PENDING_PERMISSIONS = new Map()
|
const EMPTY_PENDING_PERMISSIONS = new Map()
|
||||||
@@ -1023,9 +1024,15 @@ function DraftAgentScreenContent({
|
|||||||
git: gitOptions,
|
git: gitOptions,
|
||||||
})
|
})
|
||||||
|
|
||||||
const agentId = (result as { id?: string })?.id
|
const agentId = result.id
|
||||||
if (agentId && selectedServerId) {
|
if (agentId && selectedServerId) {
|
||||||
updatePendingAgentId(agentId)
|
updatePendingAgentId({ draftId: draftIdRef.current, agentId })
|
||||||
|
useSessionStore.getState().setAgents(selectedServerId, (prev) => {
|
||||||
|
const next = new Map(prev)
|
||||||
|
next.set(agentId, normalizeAgentSnapshot(result, selectedServerId))
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
void createAgentClient.refreshAgent(agentId).catch(() => undefined)
|
||||||
router.replace(buildHostAgentDetailRoute(selectedServerId, agentId) as any)
|
router.replace(buildHostAgentDetailRoute(selectedServerId, agentId) as any)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1035,15 +1042,15 @@ function DraftAgentScreenContent({
|
|||||||
message: 'Failed to create agent',
|
message: 'Failed to create agent',
|
||||||
})
|
})
|
||||||
onCreateFlowActiveChange?.(false)
|
onCreateFlowActiveChange?.(false)
|
||||||
markPendingCreateLifecycle('abandoned')
|
markPendingCreateLifecycle({ draftId: draftIdRef.current, lifecycle: 'abandoned' })
|
||||||
clearPendingCreateAttempt()
|
clearPendingCreateAttempt({ draftId: draftIdRef.current })
|
||||||
throw new Error('Failed to create agent')
|
throw new Error('Failed to create agent')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : 'Failed to create agent'
|
const message = error instanceof Error ? error.message : 'Failed to create agent'
|
||||||
dispatch({ type: 'CREATE_FAILED', message })
|
dispatch({ type: 'CREATE_FAILED', message })
|
||||||
onCreateFlowActiveChange?.(false)
|
onCreateFlowActiveChange?.(false)
|
||||||
markPendingCreateLifecycle('abandoned')
|
markPendingCreateLifecycle({ draftId: draftIdRef.current, lifecycle: 'abandoned' })
|
||||||
clearPendingCreateAttempt()
|
clearPendingCreateAttempt({ draftId: draftIdRef.current })
|
||||||
throw error // Re-throw so AgentInputArea knows it failed
|
throw error // Re-throw so AgentInputArea knows it failed
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,427 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useReducer } from "react";
|
||||||
|
import { Keyboard, Platform, ScrollView, Text, View } from "react-native";
|
||||||
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
|
import { AgentInputArea } from "@/components/agent-input-area";
|
||||||
|
import { AgentConfigRow } from "@/components/agent-form/agent-form-dropdowns";
|
||||||
|
import { AgentStreamView } from "@/components/agent-stream-view";
|
||||||
|
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
|
||||||
|
import { useAgentFormState } from "@/hooks/use-agent-form-state";
|
||||||
|
import { useHostRuntimeSession } from "@/runtime/host-runtime";
|
||||||
|
import { useCreateFlowStore } from "@/stores/create-flow-store";
|
||||||
|
import { useSessionStore, type Agent } from "@/stores/session-store";
|
||||||
|
import { generateMessageId, type StreamItem, type UserMessageImageAttachment } from "@/types/stream";
|
||||||
|
import { encodeImages } from "@/utils/encode-images";
|
||||||
|
import type { AgentCapabilityFlags, AgentSessionConfig } from "@server/server/agent/agent-sdk-types";
|
||||||
|
import type { AgentSnapshotPayload } from "@server/shared/messages";
|
||||||
|
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
|
||||||
|
|
||||||
|
const EMPTY_PENDING_PERMISSIONS = new Map();
|
||||||
|
const EMPTY_STREAM_ITEMS: StreamItem[] = [];
|
||||||
|
const DRAFT_CAPABILITIES: AgentCapabilityFlags = {
|
||||||
|
supportsStreaming: true,
|
||||||
|
supportsSessionPersistence: false,
|
||||||
|
supportsDynamicModes: false,
|
||||||
|
supportsMcpServers: false,
|
||||||
|
supportsReasoningStream: false,
|
||||||
|
supportsToolInvocations: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
type WorkspaceDraftAgentTabProps = {
|
||||||
|
serverId: string;
|
||||||
|
workspaceId: string;
|
||||||
|
tabId: string;
|
||||||
|
draftId: string;
|
||||||
|
onCreated: (snapshot: AgentSnapshotPayload) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CreateAttempt = {
|
||||||
|
clientMessageId: string;
|
||||||
|
text: string;
|
||||||
|
timestamp: Date;
|
||||||
|
images?: UserMessageImageAttachment[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type DraftAgentMachineState =
|
||||||
|
| { tag: "draft"; promptText: string; errorMessage: string }
|
||||||
|
| { tag: "creating"; attempt: CreateAttempt };
|
||||||
|
|
||||||
|
type DraftAgentMachineEvent =
|
||||||
|
| { type: "DRAFT_SET_PROMPT"; text: string }
|
||||||
|
| { type: "DRAFT_SET_ERROR"; message: string }
|
||||||
|
| { type: "SUBMIT"; attempt: CreateAttempt }
|
||||||
|
| { type: "CREATE_FAILED"; message: string };
|
||||||
|
|
||||||
|
function assertNever(value: never): never {
|
||||||
|
throw new Error(`Unhandled state: ${JSON.stringify(value)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WorkspaceDraftAgentTab({
|
||||||
|
serverId,
|
||||||
|
workspaceId,
|
||||||
|
tabId,
|
||||||
|
draftId,
|
||||||
|
onCreated,
|
||||||
|
}: WorkspaceDraftAgentTabProps) {
|
||||||
|
const { client, isConnected } = useHostRuntimeSession(serverId);
|
||||||
|
|
||||||
|
const setPendingCreateAttempt = useCreateFlowStore((state) => state.setPending);
|
||||||
|
const updatePendingAgentId = useCreateFlowStore((state) => state.updateAgentId);
|
||||||
|
const markPendingCreateLifecycle = useCreateFlowStore((state) => state.markLifecycle);
|
||||||
|
const clearPendingCreateAttempt = useCreateFlowStore((state) => state.clear);
|
||||||
|
|
||||||
|
const {
|
||||||
|
selectedProvider,
|
||||||
|
setProviderFromUser,
|
||||||
|
selectedMode,
|
||||||
|
setModeFromUser,
|
||||||
|
selectedModel,
|
||||||
|
setModelFromUser,
|
||||||
|
selectedThinkingOptionId,
|
||||||
|
setThinkingOptionFromUser,
|
||||||
|
workingDir,
|
||||||
|
setWorkingDir,
|
||||||
|
providerDefinitions,
|
||||||
|
modeOptions,
|
||||||
|
availableModels,
|
||||||
|
availableThinkingOptions,
|
||||||
|
isModelLoading,
|
||||||
|
persistFormPreferences,
|
||||||
|
} = useAgentFormState({
|
||||||
|
initialServerId: serverId,
|
||||||
|
initialValues: { workingDir: workspaceId },
|
||||||
|
isVisible: true,
|
||||||
|
isCreateFlow: true,
|
||||||
|
onlineServerIds: isConnected ? [serverId] : [],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Lock working directory to workspace.
|
||||||
|
useEffect(() => {
|
||||||
|
if (workingDir.trim() === workspaceId.trim()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setWorkingDir(workspaceId);
|
||||||
|
}, [setWorkingDir, workingDir, workspaceId]);
|
||||||
|
|
||||||
|
const [machine, dispatch] = useReducer(
|
||||||
|
(state: DraftAgentMachineState, event: DraftAgentMachineEvent): DraftAgentMachineState => {
|
||||||
|
switch (event.type) {
|
||||||
|
case "DRAFT_SET_PROMPT": {
|
||||||
|
if (state.tag !== "draft") {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
return { ...state, promptText: event.text };
|
||||||
|
}
|
||||||
|
case "DRAFT_SET_ERROR": {
|
||||||
|
if (state.tag !== "draft") {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
return { ...state, errorMessage: event.message };
|
||||||
|
}
|
||||||
|
case "SUBMIT": {
|
||||||
|
return { tag: "creating", attempt: event.attempt };
|
||||||
|
}
|
||||||
|
case "CREATE_FAILED": {
|
||||||
|
if (state.tag !== "creating") {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
return { tag: "draft", promptText: state.attempt.text, errorMessage: event.message };
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return assertNever(event);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ tag: "draft", promptText: "", errorMessage: "" }
|
||||||
|
);
|
||||||
|
|
||||||
|
const promptValue = machine.tag === "draft" ? machine.promptText : "";
|
||||||
|
const formErrorMessage = machine.tag === "draft" ? machine.errorMessage : "";
|
||||||
|
const isSubmitting = machine.tag === "creating";
|
||||||
|
|
||||||
|
const optimisticStreamItems = useMemo<StreamItem[]>(() => {
|
||||||
|
if (machine.tag !== "creating") {
|
||||||
|
return EMPTY_STREAM_ITEMS;
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
kind: "user_message",
|
||||||
|
id: machine.attempt.clientMessageId,
|
||||||
|
text: machine.attempt.text,
|
||||||
|
timestamp: machine.attempt.timestamp,
|
||||||
|
...(machine.attempt.images && machine.attempt.images.length > 0
|
||||||
|
? { images: machine.attempt.images }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}, [machine]);
|
||||||
|
|
||||||
|
const draftAgent = useMemo<Agent | null>(() => {
|
||||||
|
if (machine.tag !== "creating") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const now = machine.attempt.timestamp;
|
||||||
|
const model = selectedModel.trim() || null;
|
||||||
|
const thinkingOptionId = selectedThinkingOptionId.trim() || null;
|
||||||
|
const modeId = modeOptions.length > 0 && selectedMode !== "" ? selectedMode : null;
|
||||||
|
return {
|
||||||
|
serverId,
|
||||||
|
id: tabId,
|
||||||
|
provider: selectedProvider,
|
||||||
|
status: "running",
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
lastUserMessageAt: now,
|
||||||
|
lastActivityAt: now,
|
||||||
|
capabilities: DRAFT_CAPABILITIES,
|
||||||
|
currentModeId: modeId,
|
||||||
|
availableModes: [],
|
||||||
|
pendingPermissions: [],
|
||||||
|
persistence: null,
|
||||||
|
runtimeInfo: { provider: selectedProvider, sessionId: null, model, modeId },
|
||||||
|
title: "New agent",
|
||||||
|
cwd: workspaceId,
|
||||||
|
model,
|
||||||
|
thinkingOptionId,
|
||||||
|
labels: {},
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
machine,
|
||||||
|
modeOptions.length,
|
||||||
|
selectedMode,
|
||||||
|
selectedModel,
|
||||||
|
selectedProvider,
|
||||||
|
selectedThinkingOptionId,
|
||||||
|
serverId,
|
||||||
|
tabId,
|
||||||
|
workspaceId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handleCreateFromInput = useCallback(
|
||||||
|
async ({ text, images }: { text: string; images?: UserMessageImageAttachment[] }) => {
|
||||||
|
if (isSubmitting) {
|
||||||
|
throw new Error("Already loading");
|
||||||
|
}
|
||||||
|
dispatch({ type: "DRAFT_SET_ERROR", message: "" });
|
||||||
|
const trimmedPrompt = text.trim();
|
||||||
|
if (!trimmedPrompt) {
|
||||||
|
dispatch({ type: "DRAFT_SET_ERROR", message: "Initial prompt is required" });
|
||||||
|
throw new Error("Initial prompt is required");
|
||||||
|
}
|
||||||
|
if (providerDefinitions.length === 0) {
|
||||||
|
dispatch({
|
||||||
|
type: "DRAFT_SET_ERROR",
|
||||||
|
message: "No available providers on the selected host",
|
||||||
|
});
|
||||||
|
throw new Error("No available providers on the selected host");
|
||||||
|
}
|
||||||
|
if (!client) {
|
||||||
|
dispatch({ type: "DRAFT_SET_ERROR", message: "Host is not connected" });
|
||||||
|
throw new Error("Host is not connected");
|
||||||
|
}
|
||||||
|
|
||||||
|
const attempt: CreateAttempt = {
|
||||||
|
clientMessageId: generateMessageId(),
|
||||||
|
text: trimmedPrompt,
|
||||||
|
timestamp: new Date(),
|
||||||
|
...(images && images.length > 0 ? { images } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
setPendingCreateAttempt({
|
||||||
|
draftId,
|
||||||
|
serverId,
|
||||||
|
agentId: null,
|
||||||
|
clientMessageId: attempt.clientMessageId,
|
||||||
|
text: attempt.text,
|
||||||
|
timestamp: attempt.timestamp.getTime(),
|
||||||
|
...(attempt.images && attempt.images.length > 0 ? { images: attempt.images } : {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const modeId = modeOptions.length > 0 && selectedMode !== "" ? selectedMode : undefined;
|
||||||
|
const trimmedModel = selectedModel.trim();
|
||||||
|
const trimmedThinkingOptionId = selectedThinkingOptionId.trim();
|
||||||
|
const config: AgentSessionConfig = {
|
||||||
|
provider: selectedProvider,
|
||||||
|
cwd: workspaceId,
|
||||||
|
...(modeId ? { modeId } : {}),
|
||||||
|
...(trimmedModel ? { model: trimmedModel } : {}),
|
||||||
|
...(trimmedThinkingOptionId ? { thinkingOptionId: trimmedThinkingOptionId } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
void persistFormPreferences();
|
||||||
|
if (Platform.OS === "web") {
|
||||||
|
(document.activeElement as HTMLElement | null)?.blur?.();
|
||||||
|
}
|
||||||
|
Keyboard.dismiss();
|
||||||
|
dispatch({ type: "SUBMIT", attempt });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const imagesData = await encodeImages(images);
|
||||||
|
const result = await client.createAgent({
|
||||||
|
config,
|
||||||
|
labels: { ui: "true" },
|
||||||
|
initialPrompt: trimmedPrompt,
|
||||||
|
clientMessageId: attempt.clientMessageId,
|
||||||
|
...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const agentId = result.id;
|
||||||
|
updatePendingAgentId({ draftId, agentId });
|
||||||
|
|
||||||
|
const normalized = normalizeAgentSnapshot(result, serverId);
|
||||||
|
useSessionStore.getState().setAgents(serverId, (prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
next.set(agentId, normalized);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
void client.refreshAgent(agentId).catch(() => undefined);
|
||||||
|
onCreated(result);
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Failed to create agent";
|
||||||
|
dispatch({ type: "CREATE_FAILED", message });
|
||||||
|
markPendingCreateLifecycle({ draftId, lifecycle: "abandoned" });
|
||||||
|
clearPendingCreateAttempt({ draftId });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
clearPendingCreateAttempt,
|
||||||
|
client,
|
||||||
|
draftId,
|
||||||
|
isSubmitting,
|
||||||
|
markPendingCreateLifecycle,
|
||||||
|
modeOptions.length,
|
||||||
|
onCreated,
|
||||||
|
persistFormPreferences,
|
||||||
|
providerDefinitions.length,
|
||||||
|
selectedMode,
|
||||||
|
selectedModel,
|
||||||
|
selectedProvider,
|
||||||
|
selectedThinkingOptionId,
|
||||||
|
serverId,
|
||||||
|
setPendingCreateAttempt,
|
||||||
|
updatePendingAgentId,
|
||||||
|
workspaceId,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
const draftCommandConfig = useMemo(() => {
|
||||||
|
return {
|
||||||
|
provider: selectedProvider,
|
||||||
|
cwd: workspaceId,
|
||||||
|
...(modeOptions.length > 0 && selectedMode !== "" ? { modeId: selectedMode } : {}),
|
||||||
|
...(selectedModel.trim() ? { model: selectedModel.trim() } : {}),
|
||||||
|
...(selectedThinkingOptionId.trim()
|
||||||
|
? { thinkingOptionId: selectedThinkingOptionId.trim() }
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
modeOptions.length,
|
||||||
|
selectedMode,
|
||||||
|
selectedModel,
|
||||||
|
selectedProvider,
|
||||||
|
selectedThinkingOptionId,
|
||||||
|
workspaceId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<View style={styles.contentContainer}>
|
||||||
|
{machine.tag === "creating" && draftAgent ? (
|
||||||
|
<View style={styles.streamContainer}>
|
||||||
|
<AgentStreamView
|
||||||
|
agentId={tabId}
|
||||||
|
serverId={serverId}
|
||||||
|
agent={draftAgent}
|
||||||
|
streamItems={optimisticStreamItems}
|
||||||
|
pendingPermissions={EMPTY_PENDING_PERMISSIONS}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<ScrollView style={styles.scrollView} contentContainerStyle={styles.configScrollContent}>
|
||||||
|
<View style={styles.configSection}>
|
||||||
|
<AgentConfigRow
|
||||||
|
providerDefinitions={providerDefinitions}
|
||||||
|
selectedProvider={selectedProvider}
|
||||||
|
onSelectProvider={setProviderFromUser}
|
||||||
|
modeOptions={modeOptions}
|
||||||
|
selectedMode={selectedMode}
|
||||||
|
onSelectMode={setModeFromUser}
|
||||||
|
models={availableModels}
|
||||||
|
selectedModel={selectedModel}
|
||||||
|
onSelectModel={setModelFromUser}
|
||||||
|
isModelLoading={isModelLoading}
|
||||||
|
thinkingOptions={availableThinkingOptions}
|
||||||
|
selectedThinkingOptionId={selectedThinkingOptionId}
|
||||||
|
onSelectThinkingOption={setThinkingOptionFromUser}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{formErrorMessage ? (
|
||||||
|
<View style={styles.errorContainer}>
|
||||||
|
<Text style={styles.errorText}>{formErrorMessage}</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.inputAreaWrapper}>
|
||||||
|
<AgentInputArea
|
||||||
|
agentId={tabId}
|
||||||
|
serverId={serverId}
|
||||||
|
onSubmitMessage={handleCreateFromInput}
|
||||||
|
isSubmitLoading={isSubmitting}
|
||||||
|
blurOnSubmit={true}
|
||||||
|
value={promptValue}
|
||||||
|
onChangeText={(next) => dispatch({ type: "DRAFT_SET_PROMPT", text: next })}
|
||||||
|
autoFocus={machine.tag === "draft"}
|
||||||
|
commandDraftConfig={draftCommandConfig}
|
||||||
|
draftId={draftId}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create((theme) => ({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
width: "100%",
|
||||||
|
alignSelf: "center",
|
||||||
|
maxWidth: MAX_CONTENT_WIDTH,
|
||||||
|
},
|
||||||
|
contentContainer: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
streamContainer: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
scrollView: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
configScrollContent: {
|
||||||
|
paddingHorizontal: theme.spacing[4],
|
||||||
|
paddingTop: theme.spacing[4],
|
||||||
|
paddingBottom: theme.spacing[6],
|
||||||
|
},
|
||||||
|
configSection: {
|
||||||
|
gap: theme.spacing[3],
|
||||||
|
},
|
||||||
|
inputAreaWrapper: {
|
||||||
|
width: "100%",
|
||||||
|
},
|
||||||
|
errorContainer: {
|
||||||
|
marginTop: theme.spacing[2],
|
||||||
|
paddingHorizontal: theme.spacing[3],
|
||||||
|
paddingVertical: theme.spacing[2],
|
||||||
|
borderRadius: theme.borderRadius.md,
|
||||||
|
backgroundColor: theme.colors.surface2,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: theme.colors.destructive,
|
||||||
|
},
|
||||||
|
errorText: {
|
||||||
|
color: theme.colors.destructive,
|
||||||
|
},
|
||||||
|
}));
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,7 @@ import { useCreateFlowStore } from "./create-flow-store";
|
|||||||
|
|
||||||
describe("create-flow-store", () => {
|
describe("create-flow-store", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
useCreateFlowStore.setState({ pending: null });
|
useCreateFlowStore.setState({ pendingByDraftId: {} });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("tracks lifecycle transitions explicitly", () => {
|
it("tracks lifecycle transitions explicitly", () => {
|
||||||
@@ -18,10 +18,10 @@ describe("create-flow-store", () => {
|
|||||||
images: [],
|
images: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(useCreateFlowStore.getState().pending?.lifecycle).toBe("active");
|
expect(useCreateFlowStore.getState().pendingByDraftId["draft-1"]?.lifecycle).toBe("active");
|
||||||
|
|
||||||
useCreateFlowStore.getState().markLifecycle("abandoned");
|
useCreateFlowStore.getState().markLifecycle({ draftId: "draft-1", lifecycle: "abandoned" });
|
||||||
expect(useCreateFlowStore.getState().pending?.lifecycle).toBe("abandoned");
|
expect(useCreateFlowStore.getState().pendingByDraftId["draft-1"]?.lifecycle).toBe("abandoned");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rekeys draft id idempotently", () => {
|
it("rekeys draft id idempotently", () => {
|
||||||
@@ -44,6 +44,31 @@ describe("create-flow-store", () => {
|
|||||||
toDraftId: "draft-b",
|
toDraftId: "draft-b",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(useCreateFlowStore.getState().pending?.draftId).toBe("draft-b");
|
expect(useCreateFlowStore.getState().pendingByDraftId["draft-b"]?.draftId).toBe("draft-b");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("supports multiple pending attempts", () => {
|
||||||
|
useCreateFlowStore.getState().setPending({
|
||||||
|
draftId: "draft-1",
|
||||||
|
serverId: "server-1",
|
||||||
|
agentId: null,
|
||||||
|
clientMessageId: "msg-1",
|
||||||
|
text: "one",
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
|
useCreateFlowStore.getState().setPending({
|
||||||
|
draftId: "draft-2",
|
||||||
|
serverId: "server-1",
|
||||||
|
agentId: null,
|
||||||
|
clientMessageId: "msg-2",
|
||||||
|
text: "two",
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const state = useCreateFlowStore.getState();
|
||||||
|
expect(Object.keys(state.pendingByDraftId).sort()).toEqual(["draft-1", "draft-2"]);
|
||||||
|
|
||||||
|
useCreateFlowStore.getState().clear({ draftId: "draft-1" });
|
||||||
|
expect(useCreateFlowStore.getState().pendingByDraftId["draft-1"]).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,63 +15,82 @@ type PendingCreateAttempt = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type CreateFlowState = {
|
type CreateFlowState = {
|
||||||
pending: PendingCreateAttempt | null;
|
pendingByDraftId: Record<string, PendingCreateAttempt>;
|
||||||
setPending: (
|
setPending: (
|
||||||
pending: Omit<PendingCreateAttempt, "lifecycle">
|
pending: Omit<PendingCreateAttempt, "lifecycle">
|
||||||
) => void;
|
) => void;
|
||||||
updateAgentId: (agentId: string) => void;
|
updateAgentId: (input: { draftId: string; agentId: string }) => void;
|
||||||
markLifecycle: (lifecycle: CreateFlowLifecycleState) => void;
|
markLifecycle: (input: {
|
||||||
|
draftId: string;
|
||||||
|
lifecycle: CreateFlowLifecycleState;
|
||||||
|
}) => void;
|
||||||
rekeyDraft: (input: { fromDraftId: string; toDraftId: string }) => void;
|
rekeyDraft: (input: { fromDraftId: string; toDraftId: string }) => void;
|
||||||
clear: () => void;
|
clear: (input: { draftId: string }) => void;
|
||||||
|
clearAll: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useCreateFlowStore = create<CreateFlowState>((set) => ({
|
export const useCreateFlowStore = create<CreateFlowState>((set) => ({
|
||||||
pending: null,
|
pendingByDraftId: {},
|
||||||
setPending: (pending) =>
|
setPending: (pending) =>
|
||||||
set({
|
set((state) => ({
|
||||||
pending: {
|
pendingByDraftId: {
|
||||||
...pending,
|
...state.pendingByDraftId,
|
||||||
lifecycle: "active",
|
[pending.draftId]: {
|
||||||
|
...pending,
|
||||||
|
lifecycle: "active",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}),
|
})),
|
||||||
updateAgentId: (agentId) =>
|
updateAgentId: ({ draftId, agentId }) =>
|
||||||
set((state) => {
|
set((state) => {
|
||||||
if (!state.pending || state.pending.agentId === agentId) {
|
const current = state.pendingByDraftId[draftId];
|
||||||
|
if (!current || current.agentId === agentId) {
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
pending: {
|
pendingByDraftId: {
|
||||||
...state.pending,
|
...state.pendingByDraftId,
|
||||||
agentId,
|
[draftId]: { ...current, agentId },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
markLifecycle: (lifecycle) =>
|
markLifecycle: ({ draftId, lifecycle }) =>
|
||||||
set((state) => {
|
set((state) => {
|
||||||
if (!state.pending || state.pending.lifecycle === lifecycle) {
|
const current = state.pendingByDraftId[draftId];
|
||||||
|
if (!current || current.lifecycle === lifecycle) {
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
pending: {
|
pendingByDraftId: {
|
||||||
...state.pending,
|
...state.pendingByDraftId,
|
||||||
lifecycle,
|
[draftId]: { ...current, lifecycle },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
rekeyDraft: ({ fromDraftId, toDraftId }) =>
|
rekeyDraft: ({ fromDraftId, toDraftId }) =>
|
||||||
set((state) => {
|
set((state) => {
|
||||||
if (!state.pending || state.pending.draftId !== fromDraftId) {
|
const current = state.pendingByDraftId[fromDraftId];
|
||||||
|
if (!current) {
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
if (state.pending.draftId === toDraftId) {
|
if (fromDraftId === toDraftId) {
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
const { [fromDraftId]: _removed, ...rest } = state.pendingByDraftId;
|
||||||
return {
|
return {
|
||||||
pending: {
|
pendingByDraftId: {
|
||||||
...state.pending,
|
...rest,
|
||||||
draftId: toDraftId,
|
[toDraftId]: { ...current, draftId: toDraftId },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
clear: () => set({ pending: null }),
|
clear: ({ draftId }) =>
|
||||||
|
set((state) => {
|
||||||
|
if (!state.pendingByDraftId[draftId]) {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
const { [draftId]: _removed, ...rest } = state.pendingByDraftId;
|
||||||
|
return { pendingByDraftId: rest };
|
||||||
|
}),
|
||||||
|
clearAll: () => set({ pendingByDraftId: {} }),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -251,8 +251,11 @@ async function runAttachmentGc(): Promise<void> {
|
|||||||
import("@/stores/session-store"),
|
import("@/stores/session-store"),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const pendingCreate = useCreateFlowStore.getState().pending;
|
const pendingByDraftId = useCreateFlowStore.getState().pendingByDraftId;
|
||||||
if (pendingCreate?.lifecycle === "active" && pendingCreate.images) {
|
for (const pendingCreate of Object.values(pendingByDraftId)) {
|
||||||
|
if (pendingCreate.lifecycle !== "active" || !pendingCreate.images) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
for (const image of pendingCreate.images) {
|
for (const image of pendingCreate.images) {
|
||||||
referencedIds.add(image.id);
|
referencedIds.add(image.id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,98 +0,0 @@
|
|||||||
import { create } from "zustand";
|
|
||||||
import { buildWorkspaceTabPersistenceKey } from "./workspace-tabs-store";
|
|
||||||
|
|
||||||
function trimNonEmpty(value: string | null | undefined): string | null {
|
|
||||||
if (typeof value !== "string") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const trimmed = value.trim();
|
|
||||||
return trimmed.length > 0 ? trimmed : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeFilePath(value: string | null | undefined): string | null {
|
|
||||||
const trimmed = trimNonEmpty(value);
|
|
||||||
if (!trimmed) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return trimmed.replace(/\\/g, "/");
|
|
||||||
}
|
|
||||||
|
|
||||||
type WorkspaceFileTabsState = {
|
|
||||||
openFilePathsByWorkspace: Record<string, string[]>;
|
|
||||||
openFileTab: (input: {
|
|
||||||
serverId: string;
|
|
||||||
workspaceId: string;
|
|
||||||
filePath: string;
|
|
||||||
}) => void;
|
|
||||||
closeFileTab: (input: {
|
|
||||||
serverId: string;
|
|
||||||
workspaceId: string;
|
|
||||||
filePath: string;
|
|
||||||
}) => void;
|
|
||||||
isFileTabOpen: (input: {
|
|
||||||
serverId: string;
|
|
||||||
workspaceId: string;
|
|
||||||
filePath: string;
|
|
||||||
}) => boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useWorkspaceFileTabsStore = create<WorkspaceFileTabsState>()(
|
|
||||||
(set, get) => ({
|
|
||||||
openFilePathsByWorkspace: {},
|
|
||||||
openFileTab: ({ serverId, workspaceId, filePath }) => {
|
|
||||||
const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
|
|
||||||
const normalizedFilePath = normalizeFilePath(filePath);
|
|
||||||
if (!key || !normalizedFilePath) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
set((state) => {
|
|
||||||
const current = state.openFilePathsByWorkspace[key] ?? [];
|
|
||||||
if (current.includes(normalizedFilePath)) {
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
openFilePathsByWorkspace: {
|
|
||||||
...state.openFilePathsByWorkspace,
|
|
||||||
[key]: [...current, normalizedFilePath],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
},
|
|
||||||
closeFileTab: ({ serverId, workspaceId, filePath }) => {
|
|
||||||
const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
|
|
||||||
const normalizedFilePath = normalizeFilePath(filePath);
|
|
||||||
if (!key || !normalizedFilePath) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
set((state) => {
|
|
||||||
const current = state.openFilePathsByWorkspace[key] ?? [];
|
|
||||||
if (current.length === 0 || !current.includes(normalizedFilePath)) {
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
const next = current.filter((path) => path !== normalizedFilePath);
|
|
||||||
if (next.length === 0) {
|
|
||||||
const { [key]: _removed, ...rest } = state.openFilePathsByWorkspace;
|
|
||||||
return { openFilePathsByWorkspace: rest };
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
openFilePathsByWorkspace: {
|
|
||||||
...state.openFilePathsByWorkspace,
|
|
||||||
[key]: next,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
},
|
|
||||||
isFileTabOpen: ({ serverId, workspaceId, filePath }) => {
|
|
||||||
const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
|
|
||||||
const normalizedFilePath = normalizeFilePath(filePath);
|
|
||||||
if (!key || !normalizedFilePath) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const current = get().openFilePathsByWorkspace[key] ?? [];
|
|
||||||
return current.includes(normalizedFilePath);
|
|
||||||
},
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
@@ -3,25 +3,16 @@ import { create } from "zustand";
|
|||||||
import { createJSONStorage, persist } from "zustand/middleware";
|
import { createJSONStorage, persist } from "zustand/middleware";
|
||||||
|
|
||||||
export type WorkspaceTabTarget =
|
export type WorkspaceTabTarget =
|
||||||
|
| { kind: "draft"; draftId: string }
|
||||||
| { kind: "agent"; agentId: string }
|
| { kind: "agent"; agentId: string }
|
||||||
| { kind: "terminal"; terminalId: string }
|
| { kind: "terminal"; terminalId: string }
|
||||||
| { kind: "file"; path: string };
|
| { kind: "file"; path: string };
|
||||||
|
|
||||||
function normalizeKeys(keys: string[]): string[] {
|
export type WorkspaceTab = {
|
||||||
const seen = new Set<string>();
|
tabId: string;
|
||||||
const normalized: string[] = [];
|
target: WorkspaceTabTarget;
|
||||||
|
createdAt: number;
|
||||||
for (const rawKey of keys) {
|
};
|
||||||
const key = rawKey.trim();
|
|
||||||
if (!key || seen.has(key)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
seen.add(key);
|
|
||||||
normalized.push(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
return normalized;
|
|
||||||
}
|
|
||||||
|
|
||||||
function trimNonEmpty(value: string | null | undefined): string | null {
|
function trimNonEmpty(value: string | null | undefined): string | null {
|
||||||
if (typeof value !== "string") {
|
if (typeof value !== "string") {
|
||||||
@@ -31,31 +22,8 @@ function trimNonEmpty(value: string | null | undefined): string | null {
|
|||||||
return trimmed.length > 0 ? trimmed : null;
|
return trimmed.length > 0 ? trimmed : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeWorkspaceTab(
|
function normalizeWorkspaceId(value: string): string {
|
||||||
value: WorkspaceTabTarget | null | undefined
|
return value.trim().replace(/\\/g, "/").replace(/\/+$/, "");
|
||||||
): WorkspaceTabTarget | null {
|
|
||||||
if (!value || typeof value !== "object" || typeof value.kind !== "string") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (value.kind === "agent") {
|
|
||||||
const agentId = trimNonEmpty(value.agentId);
|
|
||||||
if (!agentId) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return { kind: "agent", agentId };
|
|
||||||
}
|
|
||||||
if (value.kind === "terminal") {
|
|
||||||
const terminalId = trimNonEmpty(value.terminalId);
|
|
||||||
if (!terminalId) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return { kind: "terminal", terminalId };
|
|
||||||
}
|
|
||||||
if (value.kind === "file") {
|
|
||||||
// File tabs are session-only; do not persist in workspace tab memory.
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildWorkspaceTabPersistenceKey(input: {
|
export function buildWorkspaceTabPersistenceKey(input: {
|
||||||
@@ -67,140 +35,433 @@ export function buildWorkspaceTabPersistenceKey(input: {
|
|||||||
if (!serverId || !workspaceId) {
|
if (!serverId || !workspaceId) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return `${serverId}:${workspaceId}`;
|
return `${serverId}:${normalizeWorkspaceId(workspaceId)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTabTarget(value: WorkspaceTabTarget | null | undefined): WorkspaceTabTarget | null {
|
||||||
|
if (!value || typeof value !== "object" || typeof value.kind !== "string") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (value.kind === "draft") {
|
||||||
|
const draftId = trimNonEmpty(value.draftId);
|
||||||
|
return draftId ? { kind: "draft", draftId } : null;
|
||||||
|
}
|
||||||
|
if (value.kind === "agent") {
|
||||||
|
const agentId = trimNonEmpty(value.agentId);
|
||||||
|
return agentId ? { kind: "agent", agentId } : null;
|
||||||
|
}
|
||||||
|
if (value.kind === "terminal") {
|
||||||
|
const terminalId = trimNonEmpty(value.terminalId);
|
||||||
|
return terminalId ? { kind: "terminal", terminalId } : null;
|
||||||
|
}
|
||||||
|
if (value.kind === "file") {
|
||||||
|
const path = trimNonEmpty(value.path);
|
||||||
|
return path ? { kind: "file", path: path.replace(/\\/g, "/") } : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDeterministicTabId(target: WorkspaceTabTarget): string {
|
||||||
|
if (target.kind === "draft") {
|
||||||
|
return target.draftId;
|
||||||
|
}
|
||||||
|
if (target.kind === "agent") {
|
||||||
|
return `agent_${target.agentId}`;
|
||||||
|
}
|
||||||
|
if (target.kind === "terminal") {
|
||||||
|
return `terminal_${target.terminalId}`;
|
||||||
|
}
|
||||||
|
// File tabs are session-only, so stable IDs are still useful for in-session routing.
|
||||||
|
return `file_${target.path}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function targetsEqual(left: WorkspaceTabTarget, right: WorkspaceTabTarget): boolean {
|
||||||
|
if (left.kind !== right.kind) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (left.kind === "draft" && right.kind === "draft") {
|
||||||
|
return left.draftId === right.draftId;
|
||||||
|
}
|
||||||
|
if (left.kind === "agent" && right.kind === "agent") {
|
||||||
|
return left.agentId === right.agentId;
|
||||||
|
}
|
||||||
|
if (left.kind === "terminal" && right.kind === "terminal") {
|
||||||
|
return left.terminalId === right.terminalId;
|
||||||
|
}
|
||||||
|
if (left.kind === "file" && right.kind === "file") {
|
||||||
|
return left.path === right.path;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
type WorkspaceTabsState = {
|
type WorkspaceTabsState = {
|
||||||
lastFocusedTabByWorkspace: Record<string, WorkspaceTabTarget>;
|
openTabsByWorkspace: Record<string, WorkspaceTab[]>;
|
||||||
tabOrderByWorkspace: Record<string, string[]>;
|
focusedTabIdByWorkspace: Record<string, string>;
|
||||||
setLastFocusedTab: (input: {
|
openDraftTab: (input: { serverId: string; workspaceId: string; draftId: string }) => string | null;
|
||||||
|
seedWorkspaceTabs: (input: {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
tab: WorkspaceTabTarget;
|
targets: WorkspaceTabTarget[];
|
||||||
|
focusedTabId?: string | null;
|
||||||
}) => void;
|
}) => void;
|
||||||
getLastFocusedTab: (input: {
|
openOrFocusTab: (input: {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
}) => WorkspaceTabTarget | null;
|
target: WorkspaceTabTarget;
|
||||||
setTabOrder: (input: {
|
}) => string | null;
|
||||||
|
focusTab: (input: { serverId: string; workspaceId: string; tabId: string }) => void;
|
||||||
|
closeTab: (input: { serverId: string; workspaceId: string; tabId: string }) => void;
|
||||||
|
reorderTabs: (input: { serverId: string; workspaceId: string; tabIds: string[] }) => void;
|
||||||
|
replaceTabTarget: (input: {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
keys: string[];
|
tabId: string;
|
||||||
|
target: WorkspaceTabTarget;
|
||||||
}) => void;
|
}) => void;
|
||||||
|
getWorkspaceTabs: (input: { serverId: string; workspaceId: string }) => WorkspaceTab[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useWorkspaceTabsStore = create<WorkspaceTabsState>()(
|
export const useWorkspaceTabsStore = create<WorkspaceTabsState>()(
|
||||||
persist(
|
persist(
|
||||||
(set, get) => ({
|
(set, get) => ({
|
||||||
lastFocusedTabByWorkspace: {},
|
openTabsByWorkspace: {},
|
||||||
tabOrderByWorkspace: {},
|
focusedTabIdByWorkspace: {},
|
||||||
setLastFocusedTab: ({ serverId, workspaceId, tab }) => {
|
openDraftTab: ({ serverId, workspaceId, draftId }) => {
|
||||||
const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
|
const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
|
||||||
const normalizedTab = normalizeWorkspaceTab(tab);
|
const normalizedDraftId = trimNonEmpty(draftId);
|
||||||
if (!key || !normalizedTab) {
|
if (!key || !normalizedDraftId) {
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
set((state) => {
|
|
||||||
const current = state.lastFocusedTabByWorkspace[key];
|
|
||||||
if (
|
|
||||||
current &&
|
|
||||||
current.kind === normalizedTab.kind &&
|
|
||||||
((current.kind === "agent" && normalizedTab.kind === "agent"
|
|
||||||
? current.agentId === normalizedTab.agentId
|
|
||||||
: current.kind === "terminal" && normalizedTab.kind === "terminal"
|
|
||||||
? current.terminalId === normalizedTab.terminalId
|
|
||||||
: false))
|
|
||||||
) {
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
lastFocusedTabByWorkspace: {
|
|
||||||
...state.lastFocusedTabByWorkspace,
|
|
||||||
[key]: normalizedTab,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
},
|
|
||||||
getLastFocusedTab: ({ serverId, workspaceId }) => {
|
|
||||||
const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
|
|
||||||
if (!key) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const value = get().lastFocusedTabByWorkspace[key];
|
|
||||||
return normalizeWorkspaceTab(value);
|
const target: WorkspaceTabTarget = { kind: "draft", draftId: normalizedDraftId };
|
||||||
|
const tabId = buildDeterministicTabId(target);
|
||||||
|
const now = Date.now();
|
||||||
|
set((state) => {
|
||||||
|
const current = state.openTabsByWorkspace[key] ?? [];
|
||||||
|
if (current.some((tab) => tab.tabId === tabId)) {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
focusedTabIdByWorkspace: {
|
||||||
|
...state.focusedTabIdByWorkspace,
|
||||||
|
[key]: tabId,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const nextTabs: WorkspaceTab[] = [...current, { tabId, target, createdAt: now }];
|
||||||
|
return {
|
||||||
|
openTabsByWorkspace: { ...state.openTabsByWorkspace, [key]: nextTabs },
|
||||||
|
focusedTabIdByWorkspace: { ...state.focusedTabIdByWorkspace, [key]: tabId },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return tabId;
|
||||||
},
|
},
|
||||||
setTabOrder: ({ serverId, workspaceId, keys }) => {
|
seedWorkspaceTabs: ({ serverId, workspaceId, targets, focusedTabId }) => {
|
||||||
const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
|
const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
|
||||||
if (!key) {
|
if (!key) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const normalized = normalizeKeys(keys);
|
const now = Date.now();
|
||||||
|
const nextTabs: WorkspaceTab[] = [];
|
||||||
|
const used = new Set<string>();
|
||||||
|
for (const raw of targets) {
|
||||||
|
const normalized = normalizeTabTarget(raw);
|
||||||
|
if (!normalized) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const tabId = buildDeterministicTabId(normalized);
|
||||||
|
if (used.has(tabId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
used.add(tabId);
|
||||||
|
nextTabs.push({ tabId, target: normalized, createdAt: now });
|
||||||
|
}
|
||||||
|
if (nextTabs.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const current = state.tabOrderByWorkspace[key] ?? [];
|
const current = state.openTabsByWorkspace[key] ?? [];
|
||||||
if (current.length === normalized.length) {
|
if (current.length > 0) {
|
||||||
let isSame = true;
|
return state;
|
||||||
for (let index = 0; index < current.length; index += 1) {
|
}
|
||||||
if (current[index] !== normalized[index]) {
|
const resolvedFocus = trimNonEmpty(focusedTabId) ?? nextTabs[0]?.tabId ?? null;
|
||||||
isSame = false;
|
return {
|
||||||
break;
|
openTabsByWorkspace: { ...state.openTabsByWorkspace, [key]: nextTabs },
|
||||||
}
|
...(resolvedFocus
|
||||||
}
|
? {
|
||||||
if (isSame) {
|
focusedTabIdByWorkspace: {
|
||||||
|
...state.focusedTabIdByWorkspace,
|
||||||
|
[key]: resolvedFocus,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
openOrFocusTab: ({ serverId, workspaceId, target }) => {
|
||||||
|
const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
|
||||||
|
const normalizedTarget = normalizeTabTarget(target);
|
||||||
|
if (!key || !normalizedTarget) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const tabId = buildDeterministicTabId(normalizedTarget);
|
||||||
|
const now = Date.now();
|
||||||
|
set((state) => {
|
||||||
|
const current = state.openTabsByWorkspace[key] ?? [];
|
||||||
|
const existing = current.find((tab) => tab.tabId === tabId);
|
||||||
|
if (existing) {
|
||||||
|
if (state.focusedTabIdByWorkspace[key] === tabId) {
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
focusedTabIdByWorkspace: {
|
||||||
|
...state.focusedTabIdByWorkspace,
|
||||||
|
[key]: tabId,
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
const nextTabs: WorkspaceTab[] = [...current, { tabId, target: normalizedTarget, createdAt: now }];
|
||||||
return {
|
return {
|
||||||
tabOrderByWorkspace: {
|
openTabsByWorkspace: { ...state.openTabsByWorkspace, [key]: nextTabs },
|
||||||
...state.tabOrderByWorkspace,
|
focusedTabIdByWorkspace: { ...state.focusedTabIdByWorkspace, [key]: tabId },
|
||||||
[key]: normalized,
|
};
|
||||||
|
});
|
||||||
|
return tabId;
|
||||||
|
},
|
||||||
|
focusTab: ({ serverId, workspaceId, tabId }) => {
|
||||||
|
const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
|
||||||
|
const normalizedTabId = trimNonEmpty(tabId);
|
||||||
|
if (!key || !normalizedTabId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
set((state) => {
|
||||||
|
if (state.focusedTabIdByWorkspace[key] === normalizedTabId) {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
focusedTabIdByWorkspace: {
|
||||||
|
...state.focusedTabIdByWorkspace,
|
||||||
|
[key]: normalizedTabId,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
closeTab: ({ serverId, workspaceId, tabId }) => {
|
||||||
|
const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
|
||||||
|
const normalizedTabId = trimNonEmpty(tabId);
|
||||||
|
if (!key || !normalizedTabId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
set((state) => {
|
||||||
|
const currentTabs = state.openTabsByWorkspace[key] ?? [];
|
||||||
|
if (currentTabs.length === 0 || !currentTabs.some((tab) => tab.tabId === normalizedTabId)) {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
const nextTabs = currentTabs.filter((tab) => tab.tabId !== normalizedTabId);
|
||||||
|
const nextOpenTabsByWorkspace =
|
||||||
|
nextTabs.length === 0
|
||||||
|
? (() => {
|
||||||
|
const { [key]: _removed, ...rest } = state.openTabsByWorkspace;
|
||||||
|
return rest;
|
||||||
|
})()
|
||||||
|
: { ...state.openTabsByWorkspace, [key]: nextTabs };
|
||||||
|
|
||||||
|
const currentFocused = state.focusedTabIdByWorkspace[key] ?? null;
|
||||||
|
const nextFocused =
|
||||||
|
currentFocused !== normalizedTabId
|
||||||
|
? currentFocused
|
||||||
|
: nextTabs[nextTabs.length - 1]?.tabId ?? null;
|
||||||
|
const nextFocusedByWorkspace = (() => {
|
||||||
|
if (!nextFocused) {
|
||||||
|
const { [key]: _removed, ...rest } = state.focusedTabIdByWorkspace;
|
||||||
|
return rest;
|
||||||
|
}
|
||||||
|
return { ...state.focusedTabIdByWorkspace, [key]: nextFocused };
|
||||||
|
})();
|
||||||
|
|
||||||
|
return {
|
||||||
|
openTabsByWorkspace: nextOpenTabsByWorkspace,
|
||||||
|
focusedTabIdByWorkspace: nextFocusedByWorkspace,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
reorderTabs: ({ serverId, workspaceId, tabIds }) => {
|
||||||
|
const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
|
||||||
|
if (!key) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const normalized = tabIds.map((id) => id.trim()).filter(Boolean);
|
||||||
|
set((state) => {
|
||||||
|
const current = state.openTabsByWorkspace[key] ?? [];
|
||||||
|
if (current.length <= 1) {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
const byId = new Map(current.map((tab) => [tab.tabId, tab]));
|
||||||
|
const used = new Set<string>();
|
||||||
|
const next: WorkspaceTab[] = [];
|
||||||
|
for (const id of normalized) {
|
||||||
|
const tab = byId.get(id);
|
||||||
|
if (!tab || used.has(id)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
used.add(id);
|
||||||
|
next.push(tab);
|
||||||
|
}
|
||||||
|
for (const tab of current) {
|
||||||
|
if (used.has(tab.tabId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
next.push(tab);
|
||||||
|
}
|
||||||
|
if (next.length !== current.length) {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
let same = true;
|
||||||
|
for (let i = 0; i < next.length; i += 1) {
|
||||||
|
if (next[i]?.tabId !== current[i]?.tabId) {
|
||||||
|
same = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (same) {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
openTabsByWorkspace: { ...state.openTabsByWorkspace, [key]: next },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
replaceTabTarget: ({ serverId, workspaceId, tabId, target }) => {
|
||||||
|
const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
|
||||||
|
const normalizedTabId = trimNonEmpty(tabId);
|
||||||
|
const normalizedTarget = normalizeTabTarget(target);
|
||||||
|
if (!key || !normalizedTabId || !normalizedTarget) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
set((state) => {
|
||||||
|
const current = state.openTabsByWorkspace[key] ?? [];
|
||||||
|
const idx = current.findIndex((tab) => tab.tabId === normalizedTabId);
|
||||||
|
if (idx < 0) {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
const existing = current[idx];
|
||||||
|
if (existing && targetsEqual(existing.target, normalizedTarget)) {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
const nextTabs = [...current];
|
||||||
|
nextTabs[idx] = {
|
||||||
|
tabId: normalizedTabId,
|
||||||
|
target: normalizedTarget,
|
||||||
|
createdAt: existing?.createdAt ?? Date.now(),
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
openTabsByWorkspace: { ...state.openTabsByWorkspace, [key]: nextTabs },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
getWorkspaceTabs: ({ serverId, workspaceId }) => {
|
||||||
|
const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
|
||||||
|
if (!key) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return get().openTabsByWorkspace[key] ?? [];
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: "workspace-tabs-state",
|
name: "workspace-tabs-state",
|
||||||
version: 2,
|
version: 3,
|
||||||
storage: createJSONStorage(() => AsyncStorage),
|
storage: createJSONStorage(() => AsyncStorage),
|
||||||
|
partialize: (state) => {
|
||||||
|
const nextOpenTabsByWorkspace: Record<string, WorkspaceTab[]> = {};
|
||||||
|
for (const key in state.openTabsByWorkspace) {
|
||||||
|
const tabs = state.openTabsByWorkspace[key] ?? [];
|
||||||
|
const persistedTabs = tabs.filter((tab) => tab.target.kind !== "file");
|
||||||
|
if (persistedTabs.length > 0) {
|
||||||
|
nextOpenTabsByWorkspace[key] = persistedTabs;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
openTabsByWorkspace: nextOpenTabsByWorkspace,
|
||||||
|
focusedTabIdByWorkspace: state.focusedTabIdByWorkspace,
|
||||||
|
} as WorkspaceTabsState;
|
||||||
|
},
|
||||||
migrate: (persistedState) => {
|
migrate: (persistedState) => {
|
||||||
const state = persistedState as
|
// Previous versions stored tab order + last focused tab by workspace using agent/terminal targets.
|
||||||
|
const legacy = persistedState as
|
||||||
| {
|
| {
|
||||||
lastFocusedTabByWorkspace?: Record<string, WorkspaceTabTarget>;
|
version?: number;
|
||||||
|
state?: any;
|
||||||
|
lastFocusedTabByWorkspace?: Record<string, any>;
|
||||||
tabOrderByWorkspace?: Record<string, string[]>;
|
tabOrderByWorkspace?: Record<string, string[]>;
|
||||||
}
|
}
|
||||||
| undefined;
|
| undefined;
|
||||||
|
|
||||||
const raw = state?.lastFocusedTabByWorkspace ?? {};
|
const rawState = (legacy as any)?.state ?? legacy ?? {};
|
||||||
const next: Record<string, WorkspaceTabTarget> = {};
|
const legacyFocused = rawState.lastFocusedTabByWorkspace ?? {};
|
||||||
|
const legacyOrder = rawState.tabOrderByWorkspace ?? {};
|
||||||
|
|
||||||
for (const key in raw) {
|
const openTabsByWorkspace: Record<string, WorkspaceTab[]> = {};
|
||||||
const value = raw[key];
|
const focusedTabIdByWorkspace: Record<string, string> = {};
|
||||||
const normalized = normalizeWorkspaceTab(value);
|
|
||||||
if (normalized) {
|
for (const key in legacyOrder) {
|
||||||
next[key] = normalized;
|
const list = legacyOrder[key];
|
||||||
|
if (!Array.isArray(list) || list.length === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const now = Date.now();
|
||||||
|
const tabs: WorkspaceTab[] = [];
|
||||||
|
for (const entry of list) {
|
||||||
|
const raw = typeof entry === "string" ? entry.trim() : "";
|
||||||
|
if (!raw) continue;
|
||||||
|
if (raw.startsWith("agent:")) {
|
||||||
|
const agentId = raw.slice("agent:".length).trim();
|
||||||
|
if (!agentId) continue;
|
||||||
|
tabs.push({
|
||||||
|
tabId: `agent_${agentId}`,
|
||||||
|
target: { kind: "agent", agentId },
|
||||||
|
createdAt: now,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (raw.startsWith("terminal:")) {
|
||||||
|
const terminalId = raw.slice("terminal:".length).trim();
|
||||||
|
if (!terminalId) continue;
|
||||||
|
tabs.push({
|
||||||
|
tabId: `terminal_${terminalId}`,
|
||||||
|
target: { kind: "terminal", terminalId },
|
||||||
|
createdAt: now,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (tabs.length > 0) {
|
||||||
|
openTabsByWorkspace[key] = tabs;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const rawOrder = state?.tabOrderByWorkspace ?? {};
|
for (const key in legacyFocused) {
|
||||||
const nextOrder: Record<string, string[]> = {};
|
const value = legacyFocused[key];
|
||||||
for (const key in rawOrder) {
|
if (!value || typeof value !== "object" || typeof value.kind !== "string") {
|
||||||
const list = rawOrder[key];
|
|
||||||
if (!Array.isArray(list)) {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const normalized = normalizeKeys(list.map((value) => String(value)));
|
if (value.kind === "agent" && typeof value.agentId === "string" && value.agentId.trim()) {
|
||||||
if (normalized.length > 0) {
|
focusedTabIdByWorkspace[key] = `agent_${value.agentId.trim()}`;
|
||||||
nextOrder[key] = normalized;
|
}
|
||||||
|
if (
|
||||||
|
value.kind === "terminal" &&
|
||||||
|
typeof value.terminalId === "string" &&
|
||||||
|
value.terminalId.trim()
|
||||||
|
) {
|
||||||
|
focusedTabIdByWorkspace[key] = `terminal_${value.terminalId.trim()}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...state,
|
openTabsByWorkspace,
|
||||||
lastFocusedTabByWorkspace: next,
|
focusedTabIdByWorkspace,
|
||||||
tabOrderByWorkspace: nextOrder,
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
parseHostDraftRouteFromPathname,
|
parseHostDraftRouteFromPathname,
|
||||||
parseHostWorkspaceAgentRouteFromPathname,
|
parseHostWorkspaceAgentRouteFromPathname,
|
||||||
parseHostWorkspaceFileRouteFromPathname,
|
parseHostWorkspaceFileRouteFromPathname,
|
||||||
|
parseHostWorkspaceTabRouteFromPathname,
|
||||||
parseHostWorkspaceTerminalRouteFromPathname,
|
parseHostWorkspaceTerminalRouteFromPathname,
|
||||||
parseHostWorkspaceRouteFromPathname,
|
parseHostWorkspaceRouteFromPathname,
|
||||||
} from "./host-routes";
|
} from "./host-routes";
|
||||||
@@ -73,6 +74,15 @@ describe("workspace route parsing", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("parses workspace route for /tab targets", () => {
|
||||||
|
expect(
|
||||||
|
parseHostWorkspaceRouteFromPathname("/h/local/workspace/L3RtcC9yZXBv/tab/draft_abc123")
|
||||||
|
).toEqual({
|
||||||
|
serverId: "local",
|
||||||
|
workspaceId: "/tmp/repo",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("parses workspace file route", () => {
|
it("parses workspace file route", () => {
|
||||||
const encodedPath = encodeFilePathForPathSegment("src/index.ts");
|
const encodedPath = encodeFilePathForPathSegment("src/index.ts");
|
||||||
expect(
|
expect(
|
||||||
@@ -110,6 +120,18 @@ describe("workspace route parsing", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("parses workspace tab route", () => {
|
||||||
|
expect(
|
||||||
|
parseHostWorkspaceTabRouteFromPathname(
|
||||||
|
"/h/local/workspace/L3RtcC9yZXBv/tab/draft_abc123"
|
||||||
|
)
|
||||||
|
).toEqual({
|
||||||
|
serverId: "local",
|
||||||
|
workspaceId: "/tmp/repo",
|
||||||
|
tabId: "draft_abc123",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("still parses legacy percent-encoded workspace routes", () => {
|
it("still parses legacy percent-encoded workspace routes", () => {
|
||||||
expect(
|
expect(
|
||||||
parseHostWorkspaceAgentRouteFromPathname(
|
parseHostWorkspaceAgentRouteFromPathname(
|
||||||
|
|||||||
@@ -231,6 +231,10 @@ export function parseHostWorkspaceRouteFromPathname(
|
|||||||
if (terminalIdx >= 0 && terminalIdx > workspaceIdStart) {
|
if (terminalIdx >= 0 && terminalIdx > workspaceIdStart) {
|
||||||
workspaceIdEnd = Math.min(workspaceIdEnd, terminalIdx);
|
workspaceIdEnd = Math.min(workspaceIdEnd, terminalIdx);
|
||||||
}
|
}
|
||||||
|
const tabIdx = pathname.lastIndexOf("/tab/");
|
||||||
|
if (tabIdx >= 0 && tabIdx > workspaceIdStart) {
|
||||||
|
workspaceIdEnd = Math.min(workspaceIdEnd, tabIdx);
|
||||||
|
}
|
||||||
const fileIdx = pathname.lastIndexOf("/file/");
|
const fileIdx = pathname.lastIndexOf("/file/");
|
||||||
if (fileIdx >= 0 && fileIdx > workspaceIdStart) {
|
if (fileIdx >= 0 && fileIdx > workspaceIdStart) {
|
||||||
workspaceIdEnd = Math.min(workspaceIdEnd, fileIdx);
|
workspaceIdEnd = Math.min(workspaceIdEnd, fileIdx);
|
||||||
@@ -344,6 +348,55 @@ export function parseHostWorkspaceTerminalRouteFromPathname(
|
|||||||
return { serverId, workspaceId, terminalId };
|
return { serverId, workspaceId, terminalId };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function parseHostWorkspaceTabRouteFromPathname(
|
||||||
|
pathname: string
|
||||||
|
): { serverId: string; workspaceId: string; tabId: string } | null {
|
||||||
|
const prefix = "/h/";
|
||||||
|
if (!pathname.startsWith(prefix)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const serverIdStart = prefix.length;
|
||||||
|
const serverIdEnd = pathname.indexOf("/", serverIdStart);
|
||||||
|
if (serverIdEnd < 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const rawServerId = pathname.slice(serverIdStart, serverIdEnd);
|
||||||
|
const serverId = trimNonEmpty(decodeSegment(rawServerId));
|
||||||
|
if (!serverId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const workspacePrefix = "/workspace/";
|
||||||
|
if (!pathname.startsWith(workspacePrefix, serverIdEnd)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const workspaceIdStart = serverIdEnd + workspacePrefix.length;
|
||||||
|
const tabMarker = "/tab/";
|
||||||
|
const tabIdx = pathname.lastIndexOf(tabMarker);
|
||||||
|
if (tabIdx < 0 || tabIdx <= workspaceIdStart) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawWorkspaceId = pathname.slice(workspaceIdStart, tabIdx).replace(/\/+$/, "");
|
||||||
|
const workspaceId = decodeWorkspaceIdFromPathSegment(rawWorkspaceId);
|
||||||
|
if (!workspaceId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabIdStart = tabIdx + tabMarker.length;
|
||||||
|
const tabIdEnd = pathname.indexOf("/", tabIdStart);
|
||||||
|
const rawTabId =
|
||||||
|
tabIdEnd < 0 ? pathname.slice(tabIdStart) : pathname.slice(tabIdStart, tabIdEnd);
|
||||||
|
const tabId = trimNonEmpty(decodeSegment(rawTabId));
|
||||||
|
if (!tabId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { serverId, workspaceId, tabId };
|
||||||
|
}
|
||||||
|
|
||||||
export function parseHostWorkspaceFileRouteFromPathname(
|
export function parseHostWorkspaceFileRouteFromPathname(
|
||||||
pathname: string
|
pathname: string
|
||||||
): { serverId: string; workspaceId: string; filePath: string } | null {
|
): { serverId: string; workspaceId: string; filePath: string } | null {
|
||||||
@@ -435,6 +488,19 @@ export function buildHostWorkspaceTerminalRoute(
|
|||||||
return `${base}/terminal/${encodeSegment(normalizedTerminalId)}`;
|
return `${base}/terminal/${encodeSegment(normalizedTerminalId)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function buildHostWorkspaceTabRoute(
|
||||||
|
serverId: string,
|
||||||
|
workspaceId: string,
|
||||||
|
tabId: string
|
||||||
|
): string {
|
||||||
|
const base = buildHostWorkspaceRoute(serverId, workspaceId);
|
||||||
|
const normalizedTabId = trimNonEmpty(tabId);
|
||||||
|
if (base === "/" || !normalizedTabId) {
|
||||||
|
return "/";
|
||||||
|
}
|
||||||
|
return `${base}/tab/${encodeSegment(normalizedTabId)}`;
|
||||||
|
}
|
||||||
|
|
||||||
export function buildHostWorkspaceFileRoute(
|
export function buildHostWorkspaceFileRoute(
|
||||||
serverId: string,
|
serverId: string,
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { mkdtemp, mkdir, rm, symlink, writeFile } from "node:fs/promises";
|
|||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { listDirectoryEntries } from "./service.js";
|
import { listDirectoryEntries, readExplorerFile } from "./service.js";
|
||||||
|
|
||||||
async function createTempDir(prefix: string): Promise<string> {
|
async function createTempDir(prefix: string): Promise<string> {
|
||||||
return mkdtemp(path.join(os.tmpdir(), prefix));
|
return mkdtemp(path.join(os.tmpdir(), prefix));
|
||||||
@@ -31,4 +31,69 @@ describe("file explorer service", () => {
|
|||||||
await rm(root, { recursive: true, force: true });
|
await rm(root, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("reads .ex files as text", async () => {
|
||||||
|
const root = await createTempDir("paseo-file-explorer-");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const filePath = path.join(root, "sample.ex");
|
||||||
|
const content = "defmodule Sample do\nend\n";
|
||||||
|
await writeFile(filePath, content, "utf-8");
|
||||||
|
|
||||||
|
const result = await readExplorerFile({
|
||||||
|
root,
|
||||||
|
relativePath: "sample.ex",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.kind).toBe("text");
|
||||||
|
expect(result.encoding).toBe("utf-8");
|
||||||
|
expect(result.mimeType).toBe("text/plain");
|
||||||
|
expect(result.content).toBe(content);
|
||||||
|
} finally {
|
||||||
|
await rm(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads unknown extension text files as text", async () => {
|
||||||
|
const root = await createTempDir("paseo-file-explorer-");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const filePath = path.join(root, "notes.customext");
|
||||||
|
const content = "hello from a custom text file\n";
|
||||||
|
await writeFile(filePath, content, "utf-8");
|
||||||
|
|
||||||
|
const result = await readExplorerFile({
|
||||||
|
root,
|
||||||
|
relativePath: "notes.customext",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.kind).toBe("text");
|
||||||
|
expect(result.encoding).toBe("utf-8");
|
||||||
|
expect(result.mimeType).toBe("text/plain");
|
||||||
|
expect(result.content).toBe(content);
|
||||||
|
} finally {
|
||||||
|
await rm(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("classifies files with null bytes as binary", async () => {
|
||||||
|
const root = await createTempDir("paseo-file-explorer-");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const filePath = path.join(root, "blob.weird");
|
||||||
|
await writeFile(filePath, Buffer.from([0x48, 0x65, 0x00, 0x6c, 0x6f]));
|
||||||
|
|
||||||
|
const result = await readExplorerFile({
|
||||||
|
root,
|
||||||
|
relativePath: "blob.weird",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.kind).toBe("binary");
|
||||||
|
expect(result.encoding).toBe("none");
|
||||||
|
expect(result.content).toBeUndefined();
|
||||||
|
expect(result.mimeType).toBe("application/octet-stream");
|
||||||
|
} finally {
|
||||||
|
await rm(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -38,24 +38,6 @@ export interface FileExplorerFile {
|
|||||||
modifiedAt: string;
|
modifiedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TEXT_EXTENSIONS = new Set([
|
|
||||||
".ts",
|
|
||||||
".tsx",
|
|
||||||
".js",
|
|
||||||
".jsx",
|
|
||||||
".json",
|
|
||||||
".md",
|
|
||||||
".txt",
|
|
||||||
".yml",
|
|
||||||
".yaml",
|
|
||||||
".css",
|
|
||||||
".scss",
|
|
||||||
".html",
|
|
||||||
".mjs",
|
|
||||||
".cjs",
|
|
||||||
".sh",
|
|
||||||
]);
|
|
||||||
|
|
||||||
const TEXT_MIME_TYPES: Record<string, string> = {
|
const TEXT_MIME_TYPES: Record<string, string> = {
|
||||||
".json": "application/json",
|
".json": "application/json",
|
||||||
};
|
};
|
||||||
@@ -156,17 +138,6 @@ export async function readExplorerFile({
|
|||||||
modifiedAt: stats.mtime.toISOString(),
|
modifiedAt: stats.mtime.toISOString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (TEXT_EXTENSIONS.has(ext)) {
|
|
||||||
const content = await fs.readFile(filePath, "utf-8");
|
|
||||||
return {
|
|
||||||
...basePayload,
|
|
||||||
kind: "text",
|
|
||||||
encoding: "utf-8",
|
|
||||||
content,
|
|
||||||
mimeType: TEXT_MIME_TYPES[ext] ?? DEFAULT_TEXT_MIME_TYPE,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ext in IMAGE_MIME_TYPES) {
|
if (ext in IMAGE_MIME_TYPES) {
|
||||||
const buffer = await fs.readFile(filePath);
|
const buffer = await fs.readFile(filePath);
|
||||||
return {
|
return {
|
||||||
@@ -178,11 +149,22 @@ export async function readExplorerFile({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const buffer = await fs.readFile(filePath);
|
||||||
|
if (isLikelyBinary(buffer)) {
|
||||||
|
return {
|
||||||
|
...basePayload,
|
||||||
|
kind: "binary",
|
||||||
|
encoding: "none",
|
||||||
|
mimeType: "application/octet-stream",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...basePayload,
|
...basePayload,
|
||||||
kind: "binary",
|
kind: "text",
|
||||||
encoding: "none",
|
encoding: "utf-8",
|
||||||
mimeType: "application/octet-stream",
|
content: buffer.toString("utf-8"),
|
||||||
|
mimeType: textMimeTypeForExtension(ext),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,11 +186,23 @@ export async function getDownloadableFileInfo({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ext = path.extname(filePath).toLowerCase();
|
const ext = path.extname(filePath).toLowerCase();
|
||||||
const mimeType = TEXT_EXTENSIONS.has(ext)
|
let mimeType = "application/octet-stream";
|
||||||
? TEXT_MIME_TYPES[ext] ?? DEFAULT_TEXT_MIME_TYPE
|
if (ext in IMAGE_MIME_TYPES) {
|
||||||
: ext in IMAGE_MIME_TYPES
|
mimeType = IMAGE_MIME_TYPES[ext];
|
||||||
? IMAGE_MIME_TYPES[ext]
|
} else {
|
||||||
: "application/octet-stream";
|
// Read only a small prefix to classify likely text vs binary.
|
||||||
|
const handle = await fs.open(filePath, "r");
|
||||||
|
const sample = Buffer.alloc(8192);
|
||||||
|
try {
|
||||||
|
const { bytesRead } = await handle.read(sample, 0, sample.length, 0);
|
||||||
|
const chunk = bytesRead < sample.length ? sample.subarray(0, bytesRead) : sample;
|
||||||
|
if (!isLikelyBinary(chunk)) {
|
||||||
|
mimeType = textMimeTypeForExtension(ext);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await handle.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
path: normalizeRelativePath({ root, targetPath: filePath }),
|
path: normalizeRelativePath({ root, targetPath: filePath }),
|
||||||
@@ -270,3 +264,33 @@ function normalizeRelativePath({
|
|||||||
const relative = path.relative(normalizedRoot, normalizedTarget);
|
const relative = path.relative(normalizedRoot, normalizedTarget);
|
||||||
return relative === "" ? "." : relative.split(path.sep).join("/");
|
return relative === "" ? "." : relative.split(path.sep).join("/");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function textMimeTypeForExtension(ext: string): string {
|
||||||
|
return TEXT_MIME_TYPES[ext] ?? DEFAULT_TEXT_MIME_TYPE;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLikelyBinary(buffer: Buffer): boolean {
|
||||||
|
if (buffer.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let suspicious = 0;
|
||||||
|
for (let idx = 0; idx < buffer.length; idx += 1) {
|
||||||
|
const byte = buffer[idx];
|
||||||
|
if (byte === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isControl =
|
||||||
|
byte < 32 &&
|
||||||
|
byte !== 9 && // tab
|
||||||
|
byte !== 10 && // newline
|
||||||
|
byte !== 13; // carriage return
|
||||||
|
|
||||||
|
if (isControl || byte === 127) {
|
||||||
|
suspicious += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return suspicious / buffer.length > 0.3;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user