The gate that decides whether a native file drop is accepted has been wrong twice in opposite directions, both times because it tried to be precise about *which points* a dialog covers: - Round 1 asked `el.contains(elementFromPoint(x, y))` and was handed the inner xterm host while the overlays are siblings, so the always-rendered Following/Paused button made the terminal's top-right corner permanently refuse drops. - Round 2 replaced that with "is a blocking overlay painted here?" and deleted the document-wide gate. `elementFromPoint` returns the *topmost* element, and ToastHost is z-[60] against the Modal backdrop's z-50 in the same stacking context — so a refused drop pushed a toast, the toast covered the dialog, and the next drop released on it was reported clear and landed in the directory the dialog was covering. The gate armed its own hole. Split the two questions instead of merging them: - Geometry answers *whose* drop it is (rect hit test, unchanged), so exactly one listener speaks for a drop and a hidden pane's zero-size rect still keeps TerminalView and FilesTab from both firing. - `dropIsBlocked` answers whether the app should take a drop at all — document-wide, no z-index in it. While a modal or blocking overlay is on screen anywhere, every drop is refused. There is no `elementFromPoint` call left, so no future overlay can become a drop hole by being painted high enough and no chrome can become a dead zone by being painted at all. The cost is over-refusal while a dialog is open, in a state the user entered deliberately, announced, writing nothing. Also: - `[aria-hidden="true"]` no longer disqualifies a blocker. It is not a visibility statement (it sits on visible decorative content), so a blocker nested in such a wrapper would have silently stopped blocking. - Modal drops `data-blocks-drop` when its pane hides, and moves focus out of itself rather than leaving it inside a `display:none` panel. - The refusal notice stays `kind: "info"` (an expected refusal is not an error, and an error card never auto-dismisses) and carries a `dedupeKey`, so repeated refusals replace rather than stack. Tests: mutation-checked against the previous implementation — four in dropTarget.test.ts, two in each of TerminalView/FilesTab, two in Modal. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
417 lines
14 KiB
TypeScript
417 lines
14 KiB
TypeScript
import { create } from "zustand";
|
|
import type { Project, TerminalSession, AppSettings, UpdateInfo, ImageUpdateInfo } from "../lib/types";
|
|
|
|
const SIDEBAR_COLLAPSED_KEY = "triple-c.sidebar.collapsed";
|
|
|
|
function loadSidebarCollapsed(): boolean {
|
|
try {
|
|
return localStorage.getItem(SIDEBAR_COLLAPSED_KEY) === "1";
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function persistSidebarCollapsed(value: boolean) {
|
|
try {
|
|
localStorage.setItem(SIDEBAR_COLLAPSED_KEY, value ? "1" : "0");
|
|
} catch {
|
|
// ignore — storage may be unavailable
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The main area hosts two tab kinds — terminals and Project Home views — in a
|
|
* single ordered strip. Tabs are addressed by a string key so one array can
|
|
* hold both kinds.
|
|
*/
|
|
export const terminalTabKey = (sessionId: string) => `term:${sessionId}`;
|
|
export const homeTabKey = (projectId: string) => `home:${projectId}`;
|
|
export const isTerminalTab = (key: string) => key.startsWith("term:");
|
|
export const isHomeTab = (key: string) => key.startsWith("home:");
|
|
export const tabKeyId = (key: string) => key.slice(key.indexOf(":") + 1);
|
|
|
|
/** activeSessionId is derived from the active tab so exactly one thing is "current". */
|
|
function activation(activeTabKey: string | null) {
|
|
return {
|
|
activeTabKey,
|
|
activeSessionId:
|
|
activeTabKey && isTerminalTab(activeTabKey) ? tabKeyId(activeTabKey) : null,
|
|
};
|
|
}
|
|
|
|
export type ToastKind = "error" | "success" | "info";
|
|
|
|
export interface Toast {
|
|
id: string;
|
|
kind: ToastKind;
|
|
message: string;
|
|
/** Long text (e.g. a bollard error) shown behind a "Details" disclosure. */
|
|
detail?: string;
|
|
/**
|
|
* Optional identity for a *recurring* notice. Pushing another toast with the
|
|
* same key replaces the one already on screen instead of stacking a second
|
|
* copy of it — three refused file drops leave one card, not three.
|
|
*/
|
|
dedupeKey?: string;
|
|
}
|
|
|
|
let toastCounter = 0;
|
|
|
|
interface AppState {
|
|
// Projects
|
|
projects: Project[];
|
|
selectedProjectId: string | null;
|
|
setProjects: (projects: Project[]) => void;
|
|
setSelectedProject: (id: string | null) => void;
|
|
updateProjectInList: (project: Project) => void;
|
|
removeProjectFromList: (id: string) => void;
|
|
|
|
// Terminal sessions
|
|
sessions: TerminalSession[];
|
|
activeSessionId: string | null;
|
|
addSession: (session: TerminalSession) => void;
|
|
removeSession: (id: string) => void;
|
|
setActiveSession: (id: string | null) => void;
|
|
|
|
// Main-area tab strip (terminals + Project Home views)
|
|
tabOrder: string[];
|
|
activeTabKey: string | null;
|
|
openProjectHome: (projectId: string) => void;
|
|
/**
|
|
* Open a project's home tab *on a particular sub-tab*.
|
|
*
|
|
* The sub-tab is local state inside `ProjectHome`, so this parks a request
|
|
* here for it to pick up: an action taken somewhere else entirely — opening a
|
|
* page in the container's browser from a terminal — has to be able to land
|
|
* the user on the pane that shows the result.
|
|
*/
|
|
openProjectHomeTab: (projectId: string, tab: string) => void;
|
|
/** Consumed once by `ProjectHome`, then cleared. */
|
|
pendingHomeTab: { projectId: string; tab: string } | null;
|
|
clearPendingHomeTab: () => void;
|
|
closeHomeTab: (projectId: string) => void;
|
|
setActiveTabKey: (key: string) => void;
|
|
cycleTab: (delta: number) => void;
|
|
focusTabIndex: (index: number) => void;
|
|
/** Reorder: put `key` at `toIndex` in the strip. Never changes what's active. */
|
|
moveTab: (key: string, toIndex: number) => void;
|
|
/** Nudge the active tab left/right — the keyboard route to the same thing. */
|
|
moveActiveTab: (delta: number) => void;
|
|
|
|
// Inline container progress, replacing the blocking progress modal.
|
|
containerProgress: Record<string, string>;
|
|
setContainerProgress: (projectId: string, message: string | null) => void;
|
|
/** Wall-clock ms when a project was observed transitioning to "running". */
|
|
runningSince: Record<string, number>;
|
|
|
|
// Toasts
|
|
toasts: Toast[];
|
|
pushToast: (toast: Omit<Toast, "id">) => string;
|
|
dismissToast: (id: string) => void;
|
|
|
|
// UI state
|
|
terminalHasSelection: boolean;
|
|
setTerminalHasSelection: (has: boolean) => void;
|
|
// STT toggle for the active session, registered by App so the terminal's
|
|
// Ctrl+Shift+M shortcut can trigger the single status-bar mic instance.
|
|
sttToggle: () => void;
|
|
setSttToggle: (fn: () => void) => void;
|
|
// Active terminal scroll state, surfaced so the status bar can host the
|
|
// "Jump to Current" control. Only the active TerminalView writes these.
|
|
terminalAtBottom: boolean;
|
|
setTerminalAtBottom: (v: boolean) => void;
|
|
scrollActiveToBottom: () => void;
|
|
setScrollActiveToBottom: (fn: () => void) => void;
|
|
sidebarView: "projects" | "settings";
|
|
setSidebarView: (view: "projects" | "settings") => void;
|
|
sidebarCollapsed: boolean;
|
|
setSidebarCollapsed: (collapsed: boolean) => void;
|
|
toggleSidebarCollapsed: () => void;
|
|
dockerAvailable: boolean | null;
|
|
setDockerAvailable: (available: boolean | null) => void;
|
|
imageExists: boolean | null;
|
|
setImageExists: (exists: boolean | null) => void;
|
|
// App settings
|
|
appSettings: AppSettings | null;
|
|
setAppSettings: (settings: AppSettings) => void;
|
|
|
|
// Update info
|
|
updateInfo: UpdateInfo | null;
|
|
setUpdateInfo: (info: UpdateInfo | null) => void;
|
|
appVersion: string;
|
|
setAppVersion: (version: string) => void;
|
|
|
|
// Image update info
|
|
imageUpdateInfo: ImageUpdateInfo | null;
|
|
setImageUpdateInfo: (info: ImageUpdateInfo | null) => void;
|
|
}
|
|
|
|
/** Track running-since transitions so Overview can show an uptime. */
|
|
function trackRunning(
|
|
previous: Project[],
|
|
next: Project[],
|
|
runningSince: Record<string, number>,
|
|
): Record<string, number> {
|
|
let changed = false;
|
|
const result = { ...runningSince };
|
|
const now = Date.now();
|
|
for (const project of next) {
|
|
const before = previous.find((p) => p.id === project.id);
|
|
if (project.status === "running") {
|
|
if (result[project.id] === undefined) {
|
|
result[project.id] = now;
|
|
changed = true;
|
|
}
|
|
} else if (before?.status === "running" || result[project.id] !== undefined) {
|
|
delete result[project.id];
|
|
changed = true;
|
|
}
|
|
}
|
|
return changed ? result : runningSince;
|
|
}
|
|
|
|
export const useAppState = create<AppState>((set) => ({
|
|
// Projects
|
|
projects: [],
|
|
selectedProjectId: null,
|
|
setProjects: (projects) =>
|
|
set((state) => ({
|
|
projects,
|
|
runningSince: trackRunning(state.projects, projects, state.runningSince),
|
|
})),
|
|
setSelectedProject: (id) => set({ selectedProjectId: id }),
|
|
updateProjectInList: (project) =>
|
|
set((state) => {
|
|
const projects = state.projects.map((p) =>
|
|
p.id === project.id ? project : p,
|
|
);
|
|
return {
|
|
projects,
|
|
runningSince: trackRunning(state.projects, projects, state.runningSince),
|
|
};
|
|
}),
|
|
removeProjectFromList: (id) =>
|
|
set((state) => {
|
|
const key = homeTabKey(id);
|
|
const tabOrder = state.tabOrder.filter((k) => k !== key);
|
|
const activeTabKey =
|
|
state.activeTabKey === key
|
|
? (tabOrder[tabOrder.length - 1] ?? null)
|
|
: state.activeTabKey;
|
|
return {
|
|
projects: state.projects.filter((p) => p.id !== id),
|
|
selectedProjectId:
|
|
state.selectedProjectId === id ? null : state.selectedProjectId,
|
|
tabOrder,
|
|
...activation(activeTabKey),
|
|
};
|
|
}),
|
|
|
|
// Terminal sessions
|
|
sessions: [],
|
|
activeSessionId: null,
|
|
addSession: (session) =>
|
|
set((state) => {
|
|
const key = terminalTabKey(session.id);
|
|
return {
|
|
sessions: [...state.sessions, session],
|
|
tabOrder: state.tabOrder.includes(key)
|
|
? state.tabOrder
|
|
: [...state.tabOrder, key],
|
|
...activation(key),
|
|
};
|
|
}),
|
|
removeSession: (id) =>
|
|
set((state) => {
|
|
const key = terminalTabKey(id);
|
|
const index = state.tabOrder.indexOf(key);
|
|
const tabOrder = state.tabOrder.filter((k) => k !== key);
|
|
const activeTabKey =
|
|
state.activeTabKey === key
|
|
? (tabOrder[Math.min(Math.max(index, 0), tabOrder.length - 1)] ?? null)
|
|
: state.activeTabKey;
|
|
return {
|
|
sessions: state.sessions.filter((s) => s.id !== id),
|
|
tabOrder,
|
|
...activation(activeTabKey),
|
|
};
|
|
}),
|
|
setActiveSession: (id) =>
|
|
set(() => activation(id === null ? null : terminalTabKey(id))),
|
|
|
|
// Main-area tabs
|
|
tabOrder: [],
|
|
activeTabKey: null,
|
|
openProjectHome: (projectId) =>
|
|
set((state) => {
|
|
const key = homeTabKey(projectId);
|
|
return {
|
|
selectedProjectId: projectId,
|
|
tabOrder: state.tabOrder.includes(key)
|
|
? state.tabOrder
|
|
: [...state.tabOrder, key],
|
|
...activation(key),
|
|
};
|
|
}),
|
|
openProjectHomeTab: (projectId, tab) =>
|
|
set((state) => {
|
|
const key = homeTabKey(projectId);
|
|
return {
|
|
selectedProjectId: projectId,
|
|
tabOrder: state.tabOrder.includes(key)
|
|
? state.tabOrder
|
|
: [...state.tabOrder, key],
|
|
pendingHomeTab: { projectId, tab },
|
|
...activation(key),
|
|
};
|
|
}),
|
|
pendingHomeTab: null,
|
|
clearPendingHomeTab: () => set({ pendingHomeTab: null }),
|
|
closeHomeTab: (projectId) =>
|
|
set((state) => {
|
|
const key = homeTabKey(projectId);
|
|
const index = state.tabOrder.indexOf(key);
|
|
if (index === -1) return {};
|
|
const tabOrder = state.tabOrder.filter((k) => k !== key);
|
|
const activeTabKey =
|
|
state.activeTabKey === key
|
|
? (tabOrder[Math.min(index, tabOrder.length - 1)] ?? null)
|
|
: state.activeTabKey;
|
|
return { tabOrder, ...activation(activeTabKey) };
|
|
}),
|
|
setActiveTabKey: (key) =>
|
|
set((state) => {
|
|
if (!state.tabOrder.includes(key)) return {};
|
|
const patch = activation(key);
|
|
return isHomeTab(key)
|
|
? { ...patch, selectedProjectId: tabKeyId(key) }
|
|
: patch;
|
|
}),
|
|
cycleTab: (delta) =>
|
|
set((state) => {
|
|
if (state.tabOrder.length === 0) return {};
|
|
const current = state.activeTabKey
|
|
? state.tabOrder.indexOf(state.activeTabKey)
|
|
: -1;
|
|
const next =
|
|
(current + delta + state.tabOrder.length) % state.tabOrder.length;
|
|
const key = state.tabOrder[next];
|
|
const patch = activation(key);
|
|
return isHomeTab(key)
|
|
? { ...patch, selectedProjectId: tabKeyId(key) }
|
|
: patch;
|
|
}),
|
|
focusTabIndex: (index) =>
|
|
set((state) => {
|
|
const key = state.tabOrder[index];
|
|
if (!key) return {};
|
|
const patch = activation(key);
|
|
return isHomeTab(key)
|
|
? { ...patch, selectedProjectId: tabKeyId(key) }
|
|
: patch;
|
|
}),
|
|
// Reordering is deliberately *only* a reordering: dragging a tab does not
|
|
// select it, so a drag can be aimed at a background tab without yanking the
|
|
// main area (and a running terminal's focus) away mid-gesture.
|
|
moveTab: (key, toIndex) =>
|
|
set((state) => {
|
|
const from = state.tabOrder.indexOf(key);
|
|
if (from === -1) return {};
|
|
const to = Math.max(0, Math.min(toIndex, state.tabOrder.length - 1));
|
|
if (from === to) return {};
|
|
const tabOrder = [...state.tabOrder];
|
|
tabOrder.splice(from, 1);
|
|
tabOrder.splice(to, 0, key);
|
|
return { tabOrder };
|
|
}),
|
|
moveActiveTab: (delta) =>
|
|
set((state) => {
|
|
const key = state.activeTabKey;
|
|
if (!key) return {};
|
|
const from = state.tabOrder.indexOf(key);
|
|
if (from === -1) return {};
|
|
// Clamped, not wrapped: a tab dragged off the end would otherwise
|
|
// reappear at the other end, which reads as a bug on a held-down key.
|
|
const to = Math.max(0, Math.min(from + delta, state.tabOrder.length - 1));
|
|
if (from === to) return {};
|
|
const tabOrder = [...state.tabOrder];
|
|
tabOrder.splice(from, 1);
|
|
tabOrder.splice(to, 0, key);
|
|
return { tabOrder };
|
|
}),
|
|
|
|
// Container progress
|
|
containerProgress: {},
|
|
setContainerProgress: (projectId, message) =>
|
|
set((state) => {
|
|
if (message === null) {
|
|
if (state.containerProgress[projectId] === undefined) return {};
|
|
const next = { ...state.containerProgress };
|
|
delete next[projectId];
|
|
return { containerProgress: next };
|
|
}
|
|
if (state.containerProgress[projectId] === message) return {};
|
|
return {
|
|
containerProgress: { ...state.containerProgress, [projectId]: message },
|
|
};
|
|
}),
|
|
runningSince: {},
|
|
|
|
// Toasts
|
|
toasts: [],
|
|
pushToast: (toast) => {
|
|
const id = `toast-${++toastCounter}`;
|
|
set((state) => {
|
|
// A keyed toast supersedes the previous one with that key. The new card
|
|
// gets a new id, so it re-mounts and its dismissal timer restarts.
|
|
const kept = toast.dedupeKey
|
|
? state.toasts.filter((t) => t.dedupeKey !== toast.dedupeKey)
|
|
: state.toasts;
|
|
return { toasts: [...kept, { ...toast, id }] };
|
|
});
|
|
return id;
|
|
},
|
|
dismissToast: (id) =>
|
|
set((state) => ({ toasts: state.toasts.filter((t) => t.id !== id) })),
|
|
|
|
// UI state
|
|
terminalHasSelection: false,
|
|
setTerminalHasSelection: (has) => set({ terminalHasSelection: has }),
|
|
sttToggle: () => {},
|
|
setSttToggle: (fn) => set({ sttToggle: fn }),
|
|
terminalAtBottom: true,
|
|
setTerminalAtBottom: (v) => set({ terminalAtBottom: v }),
|
|
scrollActiveToBottom: () => {},
|
|
setScrollActiveToBottom: (fn) => set({ scrollActiveToBottom: fn }),
|
|
sidebarView: "projects",
|
|
setSidebarView: (view) => set({ sidebarView: view }),
|
|
sidebarCollapsed: loadSidebarCollapsed(),
|
|
setSidebarCollapsed: (collapsed) => {
|
|
persistSidebarCollapsed(collapsed);
|
|
set({ sidebarCollapsed: collapsed });
|
|
},
|
|
toggleSidebarCollapsed: () =>
|
|
set((state) => {
|
|
const next = !state.sidebarCollapsed;
|
|
persistSidebarCollapsed(next);
|
|
return { sidebarCollapsed: next };
|
|
}),
|
|
dockerAvailable: null,
|
|
setDockerAvailable: (available) => set({ dockerAvailable: available }),
|
|
imageExists: null,
|
|
setImageExists: (exists) => set({ imageExists: exists }),
|
|
// App settings
|
|
appSettings: null,
|
|
setAppSettings: (settings) => set({ appSettings: settings }),
|
|
|
|
// Update info
|
|
updateInfo: null,
|
|
setUpdateInfo: (info) => set({ updateInfo: info }),
|
|
appVersion: "",
|
|
setAppVersion: (version) => set({ appVersion: version }),
|
|
|
|
// Image update info
|
|
imageUpdateInfo: null,
|
|
setImageUpdateInfo: (info) => set({ imageUpdateInfo: info }),
|
|
}));
|