diff --git a/docs/pr-screenshots/issue-239/ssh-session-taskmanager-button.png b/docs/pr-screenshots/issue-239/ssh-session-taskmanager-button.png new file mode 100644 index 00000000..053ca08c Binary files /dev/null and b/docs/pr-screenshots/issue-239/ssh-session-taskmanager-button.png differ diff --git a/docs/pr-screenshots/issue-239/ssh-session-taskmanager-open.png b/docs/pr-screenshots/issue-239/ssh-session-taskmanager-open.png new file mode 100644 index 00000000..0ef184b6 Binary files /dev/null and b/docs/pr-screenshots/issue-239/ssh-session-taskmanager-open.png differ diff --git a/docs/screenshots/issue-239/taskmanager-default.png b/docs/screenshots/issue-239/taskmanager-default.png new file mode 100644 index 00000000..ed1b64e0 Binary files /dev/null and b/docs/screenshots/issue-239/taskmanager-default.png differ diff --git a/docs/screenshots/issue-239/taskmanager-show-system.png b/docs/screenshots/issue-239/taskmanager-show-system.png new file mode 100644 index 00000000..f25e0279 Binary files /dev/null and b/docs/screenshots/issue-239/taskmanager-show-system.png differ diff --git a/packages/app/src/web/actions-shared.ts b/packages/app/src/web/actions-shared.ts index 5e42038d..f785e078 100644 --- a/packages/app/src/web/actions-shared.ts +++ b/packages/app/src/web/actions-shared.ts @@ -54,6 +54,7 @@ export type BrowserActionContext = { readonly addTerminalSession: (session: ActiveTerminalSession) => void readonly githubStatus: GithubAuthStatus | null readonly portForwardInput: string + readonly projectTasksIncludeDefault: boolean readonly reloadDashboard: () => void readonly selectedProjectId: string | null readonly selectedProjectKey: string | null @@ -76,6 +77,7 @@ export type BrowserActionContext = { readonly setProjectBrowser: Setter readonly setProjectTaskLogs: Setter readonly setProjectTasks: Setter + readonly setProjectTasksIncludeDefault: Setter readonly setSelectedMenuIndex: Setter readonly setSelectedProject: Setter readonly setSelectedProjectId: Setter diff --git a/packages/app/src/web/actions-tasks.ts b/packages/app/src/web/actions-tasks.ts index c138dd29..37038be0 100644 --- a/packages/app/src/web/actions-tasks.ts +++ b/packages/app/src/web/actions-tasks.ts @@ -1,9 +1,14 @@ -import type { Effect } from "effect" +import { Effect } from "effect" -import { type BrowserActionContext, requireSelectedProjectId, withBusy } from "./actions-shared.js" +import { type BrowserActionContext, confirmAction, requireSelectedProjectId, withBusy } from "./actions-shared.js" import { loadProjectTaskLogs, loadProjectTasks, stopProjectTask } from "./api.js" import type { ContainerTaskSnapshot } from "./api.js" +type LoadSelectedProjectTasksOptions = { + readonly includeDefault?: boolean + readonly silent?: boolean +} + const requireProjectIdForTasks = (context: BrowserActionContext): string | null => { const projectId = requireSelectedProjectId(context) if (projectId === null) { @@ -66,16 +71,20 @@ const removeTaskFromSnapshot = ( const stopSelectedProjectTaskEffect = ( selected: SelectedProjectTaskAction -): Effect.Effect => stopProjectTask(selected.projectId, selected.pid) +): Effect.Effect => + stopProjectTask(selected.projectId, selected.pid).pipe( + Effect.flatMap(() => loadProjectTasks(selected.projectId, selected.context.projectTasksIncludeDefault)) + ) const loadSelectedProjectTaskLogsEffect = ( selected: SelectedProjectTaskAction ): Effect.Effect => loadProjectTaskLogs(selected.projectId, selected.pid, 200) const applyStoppedProjectTask = ( - selected: SelectedProjectTaskAction + selected: SelectedProjectTaskAction, + snapshot: ContainerTaskSnapshot ): void => { - selected.context.setProjectTasks((snapshot) => removeTaskFromSnapshot(snapshot, selected.pid)) + selected.context.setProjectTasks(removeTaskFromSnapshot(snapshot, selected.pid)) selected.context.setMessage(`Sent SIGTERM to PID ${selected.pid}.`) } @@ -87,18 +96,16 @@ const applyLoadedProjectTaskLogs = ( selected.context.setMessage(`Loaded logs for PID ${selected.pid}.`) } -export const loadSelectedProjectTasks = ( +export const loadProjectTasksById = ( context: BrowserActionContext, - options?: { readonly silent?: boolean } + projectId: string, + options?: LoadSelectedProjectTasksOptions ) => { - const projectId = requireProjectIdForTasks(context) - if (projectId === null) { - return - } + const includeDefault = options?.includeDefault ?? context.projectTasksIncludeDefault withBusy({ context, - effect: loadProjectTasks(projectId), - label: "Loading container tasks", + effect: loadProjectTasks(projectId, includeDefault), + label: includeDefault ? "Loading all container tasks" : "Loading container tasks", onSuccess: (snapshot) => { context.setProjectTasks(snapshot) if (options?.silent !== true) { @@ -108,16 +115,46 @@ export const loadSelectedProjectTasks = ( }) } +export const loadSelectedProjectTasks = ( + context: BrowserActionContext, + options?: LoadSelectedProjectTasksOptions +) => { + const projectId = requireProjectIdForTasks(context) + if (projectId === null) { + return + } + loadProjectTasksById(context, projectId, options) +} + +export const setSelectedProjectTasksIncludeDefault = ( + context: BrowserActionContext, + includeDefault: boolean +) => { + context.setProjectTasksIncludeDefault(includeDefault) + context.setProjectTaskLogs("") + const projectId = requireProjectIdForTasks(context) + if (projectId === null) { + return + } + loadProjectTasksById(context, projectId, { includeDefault }) +} + export const stopSelectedProjectTask = ( context: BrowserActionContext, pid: number ) => { - withSelectedProjectTaskBusy({ - context, - effect: stopSelectedProjectTaskEffect, - label: "Stopping container task", - onSuccess: applyStoppedProjectTask, - pid + withSelectedProjectTask(context, pid, (selected) => { + if (!confirmAction(`Stop PID ${selected.pid}?`)) { + return + } + withBusy({ + context: selected.context, + effect: stopSelectedProjectTaskEffect(selected), + label: "Stopping container task", + onSuccess: (snapshot) => { + applyStoppedProjectTask(selected, snapshot) + } + }) }) } diff --git a/packages/app/src/web/actions.ts b/packages/app/src/web/actions.ts index e4f09af8..410e8fc4 100644 --- a/packages/app/src/web/actions.ts +++ b/packages/app/src/web/actions.ts @@ -38,7 +38,13 @@ export { loadSelectedProjectInfo, runApplyAllProjects } from "./actions-projects.js" -export { loadSelectedProjectTaskLogs, loadSelectedProjectTasks, stopSelectedProjectTask } from "./actions-tasks.js" +export { + loadProjectTasksById, + loadSelectedProjectTaskLogs, + loadSelectedProjectTasks, + setSelectedProjectTasksIncludeDefault, + stopSelectedProjectTask +} from "./actions-tasks.js" export const runBrowserMenuAction = ( currentMenu: BrowserMenuTag, diff --git a/packages/app/src/web/api-tasks.ts b/packages/app/src/web/api-tasks.ts index f7e88224..6e87e87d 100644 --- a/packages/app/src/web/api-tasks.ts +++ b/packages/app/src/web/api-tasks.ts @@ -3,10 +3,13 @@ import { Effect } from "effect" import { requestJson, requestText } from "./api-http.js" import { ContainerTaskSnapshotResponseSchema, OutputResponseSchema } from "./api-schema.js" -export const loadProjectTasks = (projectId: string) => +const projectTasksPath = (projectId: string, includeDefault: boolean): string => + `/projects/${encodeURIComponent(projectId)}/tasks${includeDefault ? "?includeDefault=true" : ""}` + +export const loadProjectTasks = (projectId: string, includeDefault = false) => requestJson( "GET", - `/projects/${encodeURIComponent(projectId)}/tasks`, + projectTasksPath(projectId, includeDefault), ContainerTaskSnapshotResponseSchema ).pipe( Effect.map((response) => response.snapshot) diff --git a/packages/app/src/web/app-ready-actions.ts b/packages/app/src/web/app-ready-actions.ts index f8321b58..d1d4b1e5 100644 --- a/packages/app/src/web/app-ready-actions.ts +++ b/packages/app/src/web/app-ready-actions.ts @@ -11,6 +11,7 @@ type ActionContextArgs = { readonly addTerminalSession: BrowserActionContext["addTerminalSession"] readonly githubStatus: BrowserActionContext["githubStatus"] readonly portForwardInput: BrowserActionContext["portForwardInput"] + readonly projectTasksIncludeDefault: BrowserActionContext["projectTasksIncludeDefault"] readonly refreshDashboard: () => void readonly selectedProjectId: string | null readonly selectedProjectKey: string | null @@ -33,6 +34,7 @@ type ActionContextArgs = { readonly setProjectBrowser: BrowserActionContext["setProjectBrowser"] readonly setProjectTaskLogs: BrowserActionContext["setProjectTaskLogs"] readonly setProjectTasks: BrowserActionContext["setProjectTasks"] + readonly setProjectTasksIncludeDefault: BrowserActionContext["setProjectTasksIncludeDefault"] readonly setSelectedMenuIndex: BrowserActionContext["setSelectedMenuIndex"] readonly setSelectedProject: BrowserActionContext["setSelectedProject"] readonly setSelectedProjectId: BrowserActionContext["setSelectedProjectId"] @@ -47,6 +49,7 @@ export const createActionContext = (args: ActionContextArgs): BrowserActionConte databaseLabelInput: args.databaseLabelInput, githubStatus: args.githubStatus, portForwardInput: args.portForwardInput, + projectTasksIncludeDefault: args.projectTasksIncludeDefault, reloadDashboard: args.refreshDashboard, selectedProjectId: args.selectedProjectId, selectedProjectKey: args.selectedProjectKey, @@ -69,6 +72,7 @@ export const createActionContext = (args: ActionContextArgs): BrowserActionConte setProjectBrowser: args.setProjectBrowser, setProjectTaskLogs: args.setProjectTaskLogs, setProjectTasks: args.setProjectTasks, + setProjectTasksIncludeDefault: args.setProjectTasksIncludeDefault, setSelectedMenuIndex: args.setSelectedMenuIndex, setSelectedProject: args.setSelectedProject, setSelectedProjectId: args.setSelectedProjectId diff --git a/packages/app/src/web/app-ready-controller-context.ts b/packages/app/src/web/app-ready-controller-context.ts index 569fb3e2..ff6978dd 100644 --- a/packages/app/src/web/app-ready-controller-context.ts +++ b/packages/app/src/web/app-ready-controller-context.ts @@ -17,6 +17,7 @@ export const createReadyActionContext = ( databaseLabelInput: state.databaseLabelInput, githubStatus: state.githubStatus, portForwardInput: state.portForwardInput, + projectTasksIncludeDefault: state.projectTasksIncludeDefault, refreshDashboard, selectedProjectId: state.selectedProjectId, selectedProjectKey: selectedProjectSummary?.projectKey ?? null, @@ -39,6 +40,7 @@ export const createReadyActionContext = ( setProjectBrowser: state.setProjectBrowser, setProjectTaskLogs: state.setProjectTaskLogs, setProjectTasks: state.setProjectTasks, + setProjectTasksIncludeDefault: state.setProjectTasksIncludeDefault, setSelectedMenuIndex: state.setSelectedMenuIndex, setSelectedProject: state.setSelectedProject, setSelectedProjectId: state.setSelectedProjectId diff --git a/packages/app/src/web/app-ready-controller.ts b/packages/app/src/web/app-ready-controller.ts index 290bc6af..566b6143 100644 --- a/packages/app/src/web/app-ready-controller.ts +++ b/packages/app/src/web/app-ready-controller.ts @@ -1,21 +1,14 @@ import { updateActionPromptValue } from "./action-prompt.js" -import { withBusy } from "./actions-shared.js" import { - applyProjectById, - applySelectedProject, - attachProjectTerminalById, cancelBrowserActionPrompt, closeSelectedProjectPort, - connectProjectById, loadSelectedProjectBrowser, loadSelectedProjectPorts, openProjectBrowserById, openSelectedProjectBrowser, openSelectedProjectPort, - runApplyAllProjects, submitBrowserActionPrompt } from "./actions.js" -import { deleteProjectTerminalSession } from "./api.js" import type { DashboardData } from "./api.js" import type { createActionContext } from "./app-ready-actions.js" import { resolveCurrentMenu, runAuthActionByIndex, runProjectAuthActionByIndex } from "./app-ready-actions.js" @@ -41,6 +34,7 @@ import { bindScreenActions } from "./app-ready-screen-actions.js" import { useSshLink } from "./app-ready-ssh-link-hook.js" import { bindTaskActions } from "./app-ready-task-actions.js" import { useProjectTasksReset } from "./app-ready-tasks-hook.js" +import { bindTerminalActions } from "./app-ready-terminal-actions.js" import { useReadyUrlSync } from "./app-ready-url.js" import { filterDashboardProjectsByQuery } from "./project-search.js" @@ -102,7 +96,8 @@ const useReadyResetEffects = (args: ReadySideEffectsArgs) => { useProjectTasksReset( args.state.selectedProjectId, args.state.setProjectTaskLogs, - args.state.setProjectTasks + args.state.setProjectTasks, + args.state.setProjectTasksIncludeDefault ) } @@ -130,6 +125,7 @@ const useReadyAutoloadEffects = (args: ReadySideEffectsArgs) => { const useReadyShortcutEffects = (args: ReadySideEffectsArgs) => { useBrowserShortcuts({ activeScreen: args.state.activeScreen, + activeTerminalSessionId: args.state.activeTerminalSessionId, actionPrompt: args.state.actionPrompt, context: args.actionContext, controllerCwd: args.dashboard.health.cwd, @@ -253,44 +249,6 @@ const bindBrowserActions = ( } }) -const bindTerminalActions = ( - actionContext: ReturnType, - state: ReturnType -) => ({ - onApplyProjectById: (projectId: string) => { - applyProjectById(projectId, actionContext) - }, - onApplySelectedProject: () => { - applySelectedProject(actionContext) - }, - onApplyAllProjects: () => { - runApplyAllProjects(actionContext) - }, - onOpenProjectTerminalById: (projectId: string, projectKey?: string) => { - connectProjectById(projectId, actionContext, projectKey) - }, - onAttachProjectTerminalSession: ( - projectId: string, - projectKey: string, - projectDisplayName: string, - sessionId: string - ) => { - attachProjectTerminalById(projectId, projectKey, projectDisplayName, sessionId, actionContext) - }, - onKillProjectTerminalSession: (_projectId: string, projectKey: string, sessionId: string) => { - withBusy({ - context: actionContext, - effect: deleteProjectTerminalSession(projectKey, sessionId), - label: "Killing SSH terminal", - onSuccess: () => { - state.closeTerminalSession(sessionId) - actionContext.reloadDashboard() - actionContext.setMessage(`Killed SSH terminal: ${sessionId}.`) - } - }) - } -}) - export const useReadyController = ({ dashboard, dashboardRefreshTick, refreshDashboard }: ReadyControllerArgs) => { const state = useReadyState() const currentMenu = resolveCurrentMenu(state.selectedMenuIndex) diff --git a/packages/app/src/web/app-ready-create.ts b/packages/app/src/web/app-ready-create.ts index e3a41246..7068100e 100644 --- a/packages/app/src/web/app-ready-create.ts +++ b/packages/app/src/web/app-ready-create.ts @@ -28,7 +28,14 @@ type CreateSubmitArgs = CreateKeyArgs & { readonly quickCreate?: boolean } -const createCharacterInput = (event: KeyboardEvent): string => event.key.length === 1 ? event.key : "" +type CreateKeyboardEvent = { + readonly key: string + readonly shiftKey: boolean + readonly preventDefault: () => void +} + +const createCharacterInput = (event: Pick): string => + event.key.length === 1 ? event.key : "" export const resetCreateView = (): CreateFlowView => createInitialFlowView() @@ -94,7 +101,7 @@ export const useCreateMenuReset = ( } export const handleCreateKey = ( - event: KeyboardEvent, + event: CreateKeyboardEvent, { context, controllerCwd, createView, projectsRoot, setCreateView }: CreateKeyArgs ): boolean => { if (event.key === "Escape") { diff --git a/packages/app/src/web/app-ready-layout.tsx b/packages/app/src/web/app-ready-layout.tsx index 5cce2f5e..6c1768de 100644 --- a/packages/app/src/web/app-ready-layout.tsx +++ b/packages/app/src/web/app-ready-layout.tsx @@ -71,6 +71,7 @@ export type ReadyLayoutProps = { readonly onOpenProjectBrowserById: (projectId: string) => void readonly onOpenProjectBrowser: () => void readonly onOpenProjectDatabaseEditor: () => void + readonly onOpenProjectTaskManagerById: (projectId: string) => void readonly onKillProjectTerminalSession: (projectId: string, projectKey: string, sessionId: string) => void readonly onOpenProjectPortForward: () => void readonly onOpenProjectTerminalById: (projectId: string, projectKey?: string) => void @@ -80,6 +81,7 @@ export type ReadyLayoutProps = { readonly onRefreshProjectBrowser: () => void readonly onRefreshProjectDatabases: () => void readonly onRefreshProjectTasks: () => void + readonly onProjectTasksIncludeDefaultChange: (includeDefault: boolean) => void readonly onRestartProjectDatabaseEditor: () => void readonly onSaveDatabaseProfile: () => void readonly onSetActiveScreen: (screen: BrowserScreen) => void @@ -100,6 +102,7 @@ export type ReadyLayoutProps = { readonly projectBrowser: ProjectBrowserSession | null readonly projectTaskLogs: string readonly projectTasks: ContainerTaskSnapshot | null + readonly projectTasksIncludeDefault: boolean readonly selectedMenuIndex: number readonly selectedProjectId: string | null readonly selectedProjectSummary: DashboardData["projects"][number] | undefined diff --git a/packages/app/src/web/app-ready-main-panels.tsx b/packages/app/src/web/app-ready-main-panels.tsx index 25293b80..6d6222a5 100644 --- a/packages/app/src/web/app-ready-main-panels.tsx +++ b/packages/app/src/web/app-ready-main-panels.tsx @@ -178,7 +178,9 @@ const DatabaseDetails = (props: MainPanelsProps): JSX.Element => ( const TaskDetails = (props: MainPanelsProps): JSX.Element => ( = Dispatch> export type BrowserShortcutArgs = { readonly activeScreen: BrowserScreen + readonly activeTerminalSessionId: string | null readonly actionPrompt: ActionPromptState | null readonly context: BrowserActionContext readonly controllerCwd: string @@ -50,10 +52,10 @@ type MenuOpenArgs = Pick< > const shouldIgnoreShortcut = ( + activeTerminalSessionId: string | null, actionPrompt: ActionPromptState | null, - event: KeyboardEvent, - terminalSessions: ReadonlyArray -): boolean => terminalSessions.length > 0 || isBlockedShortcut(event, actionPrompt !== null) + event: ShortcutKeyboardEvent +): boolean => activeTerminalSessionId !== null || isBlockedShortcut(event, actionPrompt !== null) const openSelectedMenuScreen = ({ context, @@ -78,7 +80,7 @@ const openSelectedMenuScreen = ({ } const handleMenuScreenKey = ( - event: KeyboardEvent, + event: ShortcutKeyboardEvent, { context, currentMenu, @@ -129,7 +131,7 @@ const runProjectPickerAction = ( } const handleRefreshShortcut = ( - event: KeyboardEvent, + event: ShortcutKeyboardEvent, { context, currentMenu }: Pick ): boolean => { if (event.key !== "r" && event.key !== "R") { @@ -141,7 +143,7 @@ const handleRefreshShortcut = ( } const handleProjectPickerShortcut = ( - event: KeyboardEvent, + event: ShortcutKeyboardEvent, args: Pick< BrowserShortcutArgs, | "context" @@ -181,7 +183,7 @@ const handleProjectPickerShortcut = ( } const handleBackToMenuShortcut = ( - event: KeyboardEvent, + event: ShortcutKeyboardEvent, context: BrowserActionContext, setActiveScreen: Setter, returnToProjectPicker: boolean @@ -196,7 +198,7 @@ const handleBackToMenuShortcut = ( } const handleOutputShortcut = ( - event: KeyboardEvent, + event: ShortcutKeyboardEvent, args: Pick ): void => { if (handleBackToMenuShortcut(event, args.context, args.setActiveScreen, isProjectMenu(args.currentMenu))) { @@ -206,7 +208,7 @@ const handleOutputShortcut = ( } const handleContentShortcut = ( - event: KeyboardEvent, + event: ShortcutKeyboardEvent, args: Pick ): void => { handleBackToMenuShortcut(event, args.context, args.setActiveScreen, false) @@ -214,7 +216,7 @@ const handleContentShortcut = ( } const handleMenuShortcut = ( - event: KeyboardEvent, + event: ShortcutKeyboardEvent, args: Pick< BrowserShortcutArgs, | "context" @@ -234,7 +236,7 @@ const handleMenuShortcut = ( } const handleCreateShortcut = ( - event: KeyboardEvent, + event: ShortcutKeyboardEvent, args: Pick< BrowserShortcutArgs, "context" | "controllerCwd" | "createView" | "projectsRoot" | "setActiveScreen" | "setCreateView" @@ -252,7 +254,7 @@ const handleCreateShortcut = ( } } -const dispatchActiveScreenShortcut = (event: KeyboardEvent, args: BrowserShortcutArgs): void => { +const dispatchActiveScreenShortcut = (event: ShortcutKeyboardEvent, args: BrowserShortcutArgs): void => { if (args.activeScreen.tag === "Menu") { handleMenuShortcut(event, args) return @@ -281,8 +283,8 @@ const dispatchActiveScreenShortcut = (event: KeyboardEvent, args: BrowserShortcu handleContentShortcut(event, args) } -export const dispatchBrowserShortcut = (event: KeyboardEvent, args: BrowserShortcutArgs): void => { - if (shouldIgnoreShortcut(args.actionPrompt, event, args.terminalSessions)) { +export const dispatchBrowserShortcut = (event: ShortcutKeyboardEvent, args: BrowserShortcutArgs): void => { + if (shouldIgnoreShortcut(args.activeTerminalSessionId, args.actionPrompt, event)) { return } dispatchActiveScreenShortcut(event, args) diff --git a/packages/app/src/web/app-ready-shortcuts.ts b/packages/app/src/web/app-ready-shortcuts.ts index 48bba0a5..e95f2c87 100644 --- a/packages/app/src/web/app-ready-shortcuts.ts +++ b/packages/app/src/web/app-ready-shortcuts.ts @@ -24,6 +24,7 @@ export type ShortcutKeyboardEvent = { defaultPrevented: boolean readonly key: string readonly metaKey: boolean + readonly shiftKey: boolean readonly target: EventTarget | null preventDefault: () => void } diff --git a/packages/app/src/web/app-ready-state.ts b/packages/app/src/web/app-ready-state.ts index 8e628b6f..eb7c33c1 100644 --- a/packages/app/src/web/app-ready-state.ts +++ b/packages/app/src/web/app-ready-state.ts @@ -42,6 +42,7 @@ type ReadyStateSetters = Pick< | "setProjectBrowser" | "setProjectTaskLogs" | "setProjectTasks" + | "setProjectTasksIncludeDefault" | "setSelectedMenuIndex" | "setSelectedProject" | "setSelectedProjectId" @@ -69,6 +70,7 @@ export type ReadyState = ReadyStateSetters & TerminalWorkspaceReadyState & { readonly projectBrowser: ProjectBrowserSession | null readonly projectTaskLogs: string readonly projectTasks: ContainerTaskSnapshot | null + readonly projectTasksIncludeDefault: boolean readonly setActionPrompt: Setter readonly setActiveScreen: Setter readonly setCreateView: Setter diff --git a/packages/app/src/web/app-ready-task-actions.ts b/packages/app/src/web/app-ready-task-actions.ts index 5e578332..4d108a3e 100644 --- a/packages/app/src/web/app-ready-task-actions.ts +++ b/packages/app/src/web/app-ready-task-actions.ts @@ -1,4 +1,9 @@ -import { loadSelectedProjectTaskLogs, loadSelectedProjectTasks, stopSelectedProjectTask } from "./actions.js" +import { + loadSelectedProjectTaskLogs, + loadSelectedProjectTasks, + setSelectedProjectTasksIncludeDefault, + stopSelectedProjectTask +} from "./actions.js" import type { createActionContext } from "./app-ready-actions.js" export const bindTaskActions = ( @@ -10,6 +15,9 @@ export const bindTaskActions = ( onRefreshProjectTasks: () => { loadSelectedProjectTasks(actionContext) }, + onProjectTasksIncludeDefaultChange: (includeDefault: boolean) => { + setSelectedProjectTasksIncludeDefault(actionContext, includeDefault) + }, onStopProjectTask: (pid: number) => { stopSelectedProjectTask(actionContext, pid) } diff --git a/packages/app/src/web/app-ready-tasks-hook.ts b/packages/app/src/web/app-ready-tasks-hook.ts index 80a1dcf9..401a1484 100644 --- a/packages/app/src/web/app-ready-tasks-hook.ts +++ b/packages/app/src/web/app-ready-tasks-hook.ts @@ -16,19 +16,29 @@ type TasksPanelAutoloadArgs = { export const useProjectTasksState = () => { const [projectTasks, setProjectTasks] = useState(null) const [projectTaskLogs, setProjectTaskLogs] = useState("") + const [projectTasksIncludeDefault, setProjectTasksIncludeDefault] = useState(false) - return { projectTaskLogs, projectTasks, setProjectTaskLogs, setProjectTasks } + return { + projectTaskLogs, + projectTasks, + projectTasksIncludeDefault, + setProjectTaskLogs, + setProjectTasks, + setProjectTasksIncludeDefault + } } export const useProjectTasksReset = ( selectedProjectId: string | null, setProjectTaskLogs: (value: string) => void, - setProjectTasks: (value: ContainerTaskSnapshot | null) => void + setProjectTasks: (value: ContainerTaskSnapshot | null) => void, + setProjectTasksIncludeDefault: (value: boolean) => void ) => { useEffect(() => { setProjectTaskLogs("") setProjectTasks(null) - }, [selectedProjectId, setProjectTaskLogs, setProjectTasks]) + setProjectTasksIncludeDefault(false) + }, [selectedProjectId, setProjectTaskLogs, setProjectTasks, setProjectTasksIncludeDefault]) } export const maybeLoadProjectTasks = ( diff --git a/packages/app/src/web/app-ready-terminal-actions.ts b/packages/app/src/web/app-ready-terminal-actions.ts new file mode 100644 index 00000000..77298fdb --- /dev/null +++ b/packages/app/src/web/app-ready-terminal-actions.ts @@ -0,0 +1,76 @@ +import { withBusy } from "./actions-shared.js" +import { + applyProjectById, + applySelectedProject, + attachProjectTerminalById, + connectProjectById, + loadProjectTasksById, + runApplyAllProjects +} from "./actions.js" +import { deleteProjectTerminalSession } from "./api.js" +import type { createActionContext } from "./app-ready-actions.js" +import type { ReadyState } from "./app-ready-hooks.js" +import { browserMenuIndex } from "./menu.js" + +type TerminalTaskManagerState = Pick< + ReadyState, + | "setProjectTaskLogs" + | "setProjectTasks" + | "setProjectTasksIncludeDefault" + | "setSelectedMenuIndex" + | "setSelectedProjectId" +> + +export const openTerminalTaskManager = ( + actionContext: ReturnType, + state: TerminalTaskManagerState, + projectId: string +): void => { + state.setSelectedProjectId(projectId) + state.setSelectedMenuIndex(browserMenuIndex("Tasks")) + state.setProjectTasks(null) + state.setProjectTaskLogs("") + state.setProjectTasksIncludeDefault(false) + loadProjectTasksById(actionContext, projectId, { includeDefault: false }) +} + +export const bindTerminalActions = ( + actionContext: ReturnType, + state: ReadyState +) => ({ + onApplyProjectById: (projectId: string) => { + applyProjectById(projectId, actionContext) + }, + onApplySelectedProject: () => { + applySelectedProject(actionContext) + }, + onApplyAllProjects: () => { + runApplyAllProjects(actionContext) + }, + onOpenProjectTerminalById: (projectId: string, projectKey?: string) => { + connectProjectById(projectId, actionContext, projectKey) + }, + onOpenProjectTaskManagerById: (projectId: string) => { + openTerminalTaskManager(actionContext, state, projectId) + }, + onAttachProjectTerminalSession: ( + projectId: string, + projectKey: string, + projectDisplayName: string, + sessionId: string + ) => { + attachProjectTerminalById(projectId, projectKey, projectDisplayName, sessionId, actionContext) + }, + onKillProjectTerminalSession: (_projectId: string, projectKey: string, sessionId: string) => { + withBusy({ + context: actionContext, + effect: deleteProjectTerminalSession(projectKey, sessionId), + label: "Killing SSH terminal", + onSuccess: () => { + state.closeTerminalSession(sessionId) + actionContext.reloadDashboard() + actionContext.setMessage(`Killed SSH terminal: ${sessionId}.`) + } + }) + } +}) diff --git a/packages/app/src/web/app-ready-terminal-screen.tsx b/packages/app/src/web/app-ready-terminal-screen.tsx index ae69dde6..3d72c3bc 100644 --- a/packages/app/src/web/app-ready-terminal-screen.tsx +++ b/packages/app/src/web/app-ready-terminal-screen.tsx @@ -1,27 +1,40 @@ import { Effect } from "effect" -import type { CSSProperties, JSX } from "react" +import { type CSSProperties, type JSX, useEffect, useState } from "react" import { deleteTerminalSessionByPath } from "./api.js" import { canOpenProjectBrowser } from "./app-ready-browser-openable.js" import type { ReadyLayoutProps } from "./app-ready-layout.js" import { Box, Text } from "./elements.js" +import { TaskPanel } from "./panel-tasks.js" import { TerminalPanel } from "./panel-terminal.js" import { type BrowserScreen, projectPickerScreen } from "./screen.js" import { shouldShowTerminalTabs } from "./terminal-mobile-layout.js" import { terminalSessionId } from "./terminal-state.js" import type { ActiveTerminalSession } from "./terminal.js" +type TerminalWorkspaceView = "terminal" | "tasks" + type TerminalScreenProps = Pick< ReadyLayoutProps, | "activeTerminalSessionId" | "onApplyProjectById" | "onOpenProjectBrowserById" + | "onOpenProjectTaskManagerById" | "onOpenProjectTerminalById" + | "onLoadProjectTaskLogs" + | "onProjectTasksIncludeDefaultChange" + | "onRefreshProjectTasks" | "onSelectTerminal" | "onSetActiveScreen" + | "onStopProjectTask" | "onTerminalClose" | "onTerminalMessage" + | "project" | "projectBrowser" + | "projectTaskLogs" + | "projectTasks" + | "projectTasksIncludeDefault" + | "selectedProjectSummary" | "terminalSessions" | "viewportLayout" > @@ -29,16 +42,28 @@ type TerminalScreenProps = Pick< type TerminalPaneProps = & Pick< TerminalScreenProps, - | "onOpenProjectBrowserById" | "onApplyProjectById" + | "onOpenProjectBrowserById" + | "onOpenProjectTaskManagerById" | "onOpenProjectTerminalById" + | "onLoadProjectTaskLogs" + | "onProjectTasksIncludeDefaultChange" + | "onRefreshProjectTasks" | "onSetActiveScreen" + | "onStopProjectTask" | "onTerminalClose" | "onTerminalMessage" + | "project" | "projectBrowser" + | "projectTaskLogs" + | "projectTasks" + | "projectTasksIncludeDefault" + | "selectedProjectSummary" | "viewportLayout" > & { + readonly taskManagerOpen: boolean + readonly onCloseTaskManager: () => void readonly singleSession: boolean readonly terminalSession: ActiveTerminalSession } @@ -70,8 +95,83 @@ const activeTerminalPaneStyle: CSSProperties = { overflow: "hidden" } +const taskManagerBodyStyle: CSSProperties = { + background: "#080a0d", + boxSizing: "border-box", + color: "#d6e5f7", + height: "100%", + overflow: "auto", + padding: "10px" +} + +const taskManagerToolbarStyle: CSSProperties = { + alignItems: "center", + display: "flex", + justifyContent: "flex-end", + marginBottom: "10px" +} + +const taskManagerReturnButtonStyle: CSSProperties = { + background: "#171d24", + border: "1px solid #3a4652", + borderRadius: "8px", + color: "#d6e5f7", + cursor: "pointer", + font: "inherit", + padding: "6px 10px" +} + const terminalTabLabel = (session: ActiveTerminalSession): string => session.browserProjectName ?? session.header +const TerminalTaskManagerBody = ( + { + onClose, + onLoadProjectTaskLogs, + onProjectTasksIncludeDefaultChange, + onRefreshProjectTasks, + onStopProjectTask, + project, + projectTaskLogs, + projectTasks, + projectTasksIncludeDefault, + selectedProjectSummary + }: + & Pick< + TerminalScreenProps, + | "onLoadProjectTaskLogs" + | "onProjectTasksIncludeDefaultChange" + | "onRefreshProjectTasks" + | "onStopProjectTask" + | "project" + | "projectTaskLogs" + | "projectTasks" + | "projectTasksIncludeDefault" + | "selectedProjectSummary" + > + & { + readonly onClose: () => void + } +): JSX.Element => ( +
+
+ +
+ +
+) + const TerminalTab = ( { active, @@ -209,13 +309,25 @@ const TerminalTabs = ( const TerminalPane = ( { onApplyProjectById, + onCloseTaskManager, + onLoadProjectTaskLogs, onOpenProjectBrowserById, + onOpenProjectTaskManagerById, onOpenProjectTerminalById, + onProjectTasksIncludeDefaultChange, + onRefreshProjectTasks, onSetActiveScreen, + onStopProjectTask, onTerminalClose, onTerminalMessage, + project, projectBrowser, + projectTaskLogs, + projectTasks, + projectTasksIncludeDefault, + selectedProjectSummary, singleSession, + taskManagerOpen, terminalSession, viewportLayout }: TerminalPaneProps @@ -224,6 +336,22 @@ const TerminalPane = ( const browserProjectId = terminalSession.browserProjectId const browserProjectKey = terminalSession.browserProjectKey const canOpenBrowser = canOpenProjectBrowser(projectBrowser, browserProjectId) + const bodyContent = taskManagerOpen && browserProjectId !== undefined + ? ( + + ) + : undefined const detachTerminalSession = (): void => { onTerminalClose(sessionId) if (singleSession) { @@ -233,6 +361,7 @@ const TerminalPane = ( return (
{ @@ -258,6 +387,11 @@ const TerminalPane = ( : () => { onApplyProjectById(browserProjectId) }} + onOpenTaskManager={browserProjectId === undefined + ? undefined + : () => { + onOpenProjectTaskManagerById(browserProjectId) + }} onOpenTerminal={browserProjectId === undefined ? undefined : () => { @@ -271,12 +405,16 @@ const TerminalPane = ( } export const TerminalScreen = (props: TerminalScreenProps): JSX.Element | null => { - if (props.terminalSessions.length === 0) { - return null - } + const [terminalView, setTerminalView] = useState("terminal") const mobileMode = props.viewportLayout.mode === "mobile" const activeSessionId = resolveActiveTerminalSessionId(props.terminalSessions, props.activeTerminalSessionId) const activeSession = props.terminalSessions.find((session) => terminalSessionId(session) === activeSessionId) + useEffect(() => { + setTerminalView("terminal") + }, [activeSession?.browserProjectId, activeSessionId]) + if (props.terminalSessions.length === 0) { + return null + } return ( {shouldShowTerminalTabs(mobileMode, props.terminalSessions.length) @@ -296,14 +434,31 @@ export const TerminalScreen = (props: TerminalScreenProps): JSX.Element | null = : ( { + setTerminalView("terminal") + }} + onLoadProjectTaskLogs={props.onLoadProjectTaskLogs} + onOpenProjectBrowserById={props.onOpenProjectBrowserById} + onOpenProjectTaskManagerById={(projectId) => { + setTerminalView("tasks") + props.onOpenProjectTaskManagerById(projectId) + }} onOpenProjectTerminalById={props.onOpenProjectTerminalById} + onProjectTasksIncludeDefaultChange={props.onProjectTasksIncludeDefaultChange} + onRefreshProjectTasks={props.onRefreshProjectTasks} onSetActiveScreen={props.onSetActiveScreen} + onStopProjectTask={props.onStopProjectTask} onTerminalClose={props.onTerminalClose} onTerminalMessage={props.onTerminalMessage} + project={props.project} projectBrowser={props.projectBrowser} + projectTaskLogs={props.projectTaskLogs} + projectTasks={props.projectTasks} + projectTasksIncludeDefault={props.projectTasksIncludeDefault} + selectedProjectSummary={props.selectedProjectSummary} singleSession={props.terminalSessions.length === 1} + taskManagerOpen={terminalView === "tasks"} terminalSession={activeSession} viewportLayout={props.viewportLayout} /> diff --git a/packages/app/src/web/app-ready.tsx b/packages/app/src/web/app-ready.tsx index af60a6e4..c7a6b1e9 100644 --- a/packages/app/src/web/app-ready.tsx +++ b/packages/app/src/web/app-ready.tsx @@ -39,6 +39,7 @@ type ReadyLayoutRenderArgs = { readonly onOpenProjectBrowserById: (projectId: string) => void readonly onOpenProjectBrowser: () => void readonly onOpenProjectDatabaseEditor: () => void + readonly onOpenProjectTaskManagerById: (projectId: string) => void readonly onCloseProjectPortForward: (targetPort: number) => void readonly onKillProjectTerminalSession: (projectId: string, projectKey: string, sessionId: string) => void readonly onOpenProjectPortForward: () => void @@ -49,6 +50,7 @@ type ReadyLayoutRenderArgs = { readonly onRefreshProjectBrowser: () => void readonly onRefreshProjectDatabases: () => void readonly onRefreshProjectTasks: () => void + readonly onProjectTasksIncludeDefaultChange: (includeDefault: boolean) => void readonly onRestartProjectDatabaseEditor: () => void readonly onRunAuthAction: (index: number) => void readonly onRunCurrentMenuAction: () => void @@ -87,6 +89,7 @@ const readyActionProps = (actions: ReadyLayoutRenderArgs["actions"]) => ({ onOpenProjectBrowserById: actions.onOpenProjectBrowserById, onOpenProjectBrowser: actions.onOpenProjectBrowser, onOpenProjectDatabaseEditor: actions.onOpenProjectDatabaseEditor, + onOpenProjectTaskManagerById: actions.onOpenProjectTaskManagerById, onKillProjectTerminalSession: actions.onKillProjectTerminalSession, onOpenProjectPortForward: actions.onOpenProjectPortForward, onOpenProjectTerminalById: actions.onOpenProjectTerminalById, @@ -96,6 +99,7 @@ const readyActionProps = (actions: ReadyLayoutRenderArgs["actions"]) => ({ onRefreshProjectBrowser: actions.onRefreshProjectBrowser, onRefreshProjectDatabases: actions.onRefreshProjectDatabases, onRefreshProjectTasks: actions.onRefreshProjectTasks, + onProjectTasksIncludeDefaultChange: actions.onProjectTasksIncludeDefaultChange, onRestartProjectDatabaseEditor: actions.onRestartProjectDatabaseEditor, onRunAuthAction: actions.onRunAuthAction, onRunCurrentMenuAction: actions.onRunCurrentMenuAction, @@ -134,6 +138,7 @@ const readyStateProps = (state: ReadyLayoutRenderArgs["state"]) => ({ projectBrowser: state.projectBrowser, projectTaskLogs: state.projectTaskLogs, projectTasks: state.projectTasks, + projectTasksIncludeDefault: state.projectTasksIncludeDefault, projectNavigationArmed: state.projectNavigationArmed, projectSearchQuery: state.projectSearchQuery, selectedMenuIndex: state.selectedMenuIndex, diff --git a/packages/app/src/web/panel-tasks.tsx b/packages/app/src/web/panel-tasks.tsx index 60c7b66a..39811f92 100644 --- a/packages/app/src/web/panel-tasks.tsx +++ b/packages/app/src/web/panel-tasks.tsx @@ -1,10 +1,12 @@ -import type { JSX } from "react" +import type { CSSProperties, JSX } from "react" import { Box, Text } from "../ui/primitives.js" import type { ContainerTask, ContainerTaskSnapshot, ProjectDetails, ProjectSummary } from "./api.js" type TaskPanelProps = { + readonly includeDefault: boolean readonly logs: string + readonly onIncludeDefaultChange: (includeDefault: boolean) => void readonly onLoadLogs: (pid: number) => void readonly onRefreshTasks: () => void readonly onStopTask: (pid: number) => void @@ -28,6 +30,39 @@ const kindColor = (kind: ContainerTask["kind"]): string => { const compactCommand = (command: string): string => command.length <= 120 ? command : `${command.slice(0, 117)}...` +const systemToggleStyle: CSSProperties = { + alignItems: "center", + color: "#d6e5f7", + cursor: "pointer", + display: "flex", + gap: "6px", + whiteSpace: "nowrap" +} + +const systemToggleInputStyle: CSSProperties = { + accentColor: "#78f0a3", + cursor: "pointer" +} + +const TaskSystemToggle = ( + { + includeDefault, + onIncludeDefaultChange + }: Pick +): JSX.Element => ( + +) + const TaskRow = ( { onLoadLogs, @@ -152,7 +187,9 @@ const taskPanelSummary = ( export const TaskPanel = ( { + includeDefault, logs, + onIncludeDefaultChange, onLoadLogs, onRefreshTasks, onStopTask, @@ -166,8 +203,11 @@ export const TaskPanel = ( Container tasks - - refresh + + + + refresh + diff --git a/packages/app/src/web/panel-terminal.tsx b/packages/app/src/web/panel-terminal.tsx index e64ceeeb..9273f0c9 100644 --- a/packages/app/src/web/panel-terminal.tsx +++ b/packages/app/src/web/panel-terminal.tsx @@ -26,8 +26,10 @@ type TerminalPanelProps = { readonly onKill: () => void readonly onMessage: (message: string) => void readonly onOpenBrowser?: (() => void) | undefined + readonly onOpenTaskManager?: (() => void) | undefined readonly onOpenTerminal?: (() => void) | undefined readonly session: ActiveTerminalSession + readonly bodyContent?: JSX.Element | undefined } const panelStyle: CSSProperties = { @@ -57,8 +59,9 @@ const headerStyle: CSSProperties = { const compactHeaderStyle: CSSProperties = { ...headerStyle, + flexWrap: "wrap", gap: "6px", - overflow: "hidden", + overflow: "visible", padding: "5px 6px" } @@ -86,6 +89,31 @@ const terminalBodyStyle = (compactTypingMode: boolean, mobileMode: boolean): CSS return mobileMode ? bodyStyleMobile : bodyStyle } +const terminalBodyFrameStyle = (compactTypingMode: boolean, mobileMode: boolean): CSSProperties => ({ + ...terminalBodyStyle(compactTypingMode, mobileMode), + boxSizing: "border-box", + overflow: "hidden", + position: "relative" +}) + +const terminalHostStyle: CSSProperties = { + height: "100%", + minHeight: 0, + overflow: "hidden" +} + +const terminalBodyContentStyle: CSSProperties = { + bottom: 0, + height: "100%", + left: 0, + minHeight: 0, + overflow: "auto", + position: "absolute", + right: 0, + top: 0, + zIndex: 1 +} + const closeButtonStyle: CSSProperties = { background: "#171d24", border: "1px solid #3a4652", @@ -114,7 +142,7 @@ const headerActionsStyle: CSSProperties = { const compactHeaderActionsStyle: CSSProperties = { ...headerActionsStyle, - flexWrap: "nowrap", + flexWrap: "wrap", gap: "4px" } @@ -247,6 +275,31 @@ const TerminalActionButton = ( ) +const OptionalTerminalActionButton = ( + { + compactHeaderMode, + compactLabel, + enabled, + label, + onClick + }: { + readonly compactHeaderMode: boolean + readonly compactLabel: string + readonly enabled: boolean + readonly label: string + readonly onClick: (() => void) | undefined + } +): JSX.Element | null => { + if (!enabled || onClick === undefined) { + return null + } + return ( + + {compactHeaderMode ? compactLabel : label} + + ) +} + const TerminalHeaderActions = ( { compactHeaderMode, @@ -254,47 +307,59 @@ const TerminalHeaderActions = ( onDetach, onKill, onOpenBrowser, + onOpenTaskManager, onOpenTerminal, session }: & Pick< TerminalPanelProps, - "onApplyProject" | "onDetach" | "onKill" | "onOpenBrowser" | "onOpenTerminal" | "session" + "onApplyProject" | "onDetach" | "onKill" | "onOpenBrowser" | "onOpenTaskManager" | "onOpenTerminal" | "session" > & { readonly compactHeaderMode: boolean } -): JSX.Element => ( -
- {session.browserProjectId === undefined || onOpenBrowser === undefined - ? null - : ( - - {compactHeaderMode ? "Browser" : "Open browser"} - - )} - {session.browserProjectId === undefined || onApplyProject === undefined - ? null - : ( - - Apply - - )} - {session.browserProjectId === undefined || onOpenTerminal === undefined - ? null - : ( - - {compactHeaderMode ? "New" : "New terminal"} - - )} - - Detach - - - Kill - -
-) +): JSX.Element => { + const hasProjectActions = session.browserProjectId !== undefined + + return ( +
+ + + + + + Detach + + + Kill + +
+ ) +} const TerminalHeader = ( { @@ -303,13 +368,14 @@ const TerminalHeader = ( onDetach, onKill, onOpenBrowser, + onOpenTaskManager, onOpenTerminal, session, status }: & Pick< TerminalPanelProps, - "onApplyProject" | "onDetach" | "onKill" | "onOpenBrowser" | "onOpenTerminal" | "session" + "onApplyProject" | "onDetach" | "onKill" | "onOpenBrowser" | "onOpenTaskManager" | "onOpenTerminal" | "session" > & { readonly compactHeaderMode: boolean @@ -324,6 +390,7 @@ const TerminalHeader = ( onDetach={onDetach} onKill={onKill} onOpenBrowser={onOpenBrowser} + onOpenTaskManager={onOpenTaskManager} onOpenTerminal={onOpenTerminal} session={session} /> @@ -463,6 +530,7 @@ const MobileTerminalControls = ( export const TerminalPanel = ( { + bodyContent, keyboardOpen, mobileMode, onApplyProject, @@ -471,6 +539,7 @@ export const TerminalPanel = ( onKill, onMessage, onOpenBrowser, + onOpenTaskManager, onOpenTerminal, session }: TerminalPanelProps @@ -497,6 +566,7 @@ export const TerminalPanel = ( }, []) const compactHeaderMode = resolveTerminalCompactHeaderMode(mobileMode) const compactTypingMode = resolveTerminalTypingMode(mobileMode, keyboardOpen) + const hasBodyContent = bodyContent !== undefined useEffect(() => { if (!mobileMode) { @@ -563,15 +633,18 @@ export const TerminalPanel = ( onKill() }} onOpenBrowser={onOpenBrowser} + onOpenTaskManager={onOpenTaskManager} onOpenTerminal={onOpenTerminal} session={session} status={status} />
- {mobileMode + style={terminalBodyFrameStyle(compactTypingMode, mobileMode)} + > +
+ {hasBodyContent ?
{bodyContent}
: null} +
+ {mobileMode && !hasBodyContent ? ( vi.fn()) +const loadProjectTasksMock = vi.hoisted(() => vi.fn()) +const stopProjectTaskMock = vi.hoisted(() => vi.fn()) + +vi.mock("../../src/web/api.js", () => ({ + loadProjectTaskLogs: loadProjectTaskLogsMock, + loadProjectTasks: loadProjectTasksMock, + stopProjectTask: stopProjectTaskMock +})) + +const taskSnapshot = ( + tasks: ContainerTaskSnapshot["tasks"] +): ContainerTaskSnapshot => ({ + containerName: "project-dev", + generatedAt: "2026-05-05T00:00:00.000Z", + projectId: "project-1", + sshConnections: 1, + tasks +}) + +const task = ( + pid: number, + command: string +): ContainerTaskSnapshot["tasks"][number] => ({ + command, + etime: "00:01", + etimes: 1, + kind: "background", + logAvailable: false, + pid, + ppid: 1, + tty: "?", + user: "dev" +}) + +describe("web task actions", () => { + beforeEach(() => { + loadProjectTaskLogsMock.mockReset() + loadProjectTasksMock.mockReset() + stopProjectTaskMock.mockReset() + vi.stubGlobal("confirm", vi.fn(() => true)) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it.effect("loads selected project tasks with the active system visibility flag", () => + Effect.gen(function*(_) { + const snapshot = taskSnapshot([task(42, "node server.js")]) + loadProjectTasksMock.mockImplementation(() => Effect.succeed(snapshot)) + const setProjectTasks = vi.fn() + const { context, setMessage } = makeBrowserActionContext({ + projectTasksIncludeDefault: true, + selectedProjectId: "project-1", + setProjectTasks + }) + + loadSelectedProjectTasks(context) + + yield* _(waitForAssertion(() => { + expect(setProjectTasks).toHaveBeenCalledWith(snapshot) + })) + + expect(loadProjectTasksMock).toHaveBeenCalledWith("project-1", true) + expect(setMessage).toHaveBeenLastCalledWith("Loaded 1 container task(s).") + })) + + it.effect("toggles system process visibility and reloads tasks", () => + Effect.gen(function*(_) { + const snapshot = taskSnapshot([task(1, "init"), task(42, "node server.js")]) + loadProjectTasksMock.mockImplementation(() => Effect.succeed(snapshot)) + const setProjectTaskLogs = vi.fn() + const setProjectTasks = vi.fn() + const setProjectTasksIncludeDefault = vi.fn() + const { context } = makeBrowserActionContext({ + selectedProjectId: "project-1", + setProjectTaskLogs, + setProjectTasks, + setProjectTasksIncludeDefault + }) + + setSelectedProjectTasksIncludeDefault(context, true) + + yield* _(waitForAssertion(() => { + expect(setProjectTasks).toHaveBeenCalledWith(snapshot) + })) + + expect(setProjectTasksIncludeDefault).toHaveBeenCalledWith(true) + expect(setProjectTaskLogs).toHaveBeenCalledWith("") + expect(loadProjectTasksMock).toHaveBeenCalledWith("project-1", true) + })) + + it.effect("stops a task and refreshes the snapshot before updating state", () => + Effect.gen(function*(_) { + const refreshed = taskSnapshot([task(42, "sleep 100"), task(43, "node server.js")]) + stopProjectTaskMock.mockImplementation(() => Effect.void) + loadProjectTasksMock.mockImplementation(() => Effect.succeed(refreshed)) + const setProjectTasks = vi.fn() + const { context, setMessage } = makeBrowserActionContext({ + selectedProjectId: "project-1", + setProjectTasks + }) + + stopSelectedProjectTask(context, 42) + + yield* _(waitForAssertion(() => { + expect(setProjectTasks).toHaveBeenCalled() + })) + + expect(stopProjectTaskMock).toHaveBeenCalledWith("project-1", 42) + expect(loadProjectTasksMock).toHaveBeenCalledWith("project-1", false) + expect(setProjectTasks).toHaveBeenLastCalledWith({ + ...refreshed, + tasks: [task(43, "node server.js")] + }) + expect(setMessage).toHaveBeenLastCalledWith("Sent SIGTERM to PID 42.") + })) + + it.effect("loads task logs into task log state", () => + Effect.gen(function*(_) { + loadProjectTaskLogsMock.mockImplementation(() => Effect.succeed("line one\nline two")) + const setProjectTaskLogs = vi.fn() + const { context, setMessage } = makeBrowserActionContext({ + selectedProjectId: "project-1", + setProjectTaskLogs + }) + + loadSelectedProjectTaskLogs(context, 42) + + yield* _(waitForAssertion(() => { + expect(setProjectTaskLogs).toHaveBeenCalledWith("line one\nline two") + })) + + expect(loadProjectTaskLogsMock).toHaveBeenCalledWith("project-1", 42, 200) + expect(setMessage).toHaveBeenLastCalledWith("Loaded logs for PID 42.") + })) + + it("clears task state when no project is selected", () => { + const setProjectTaskLogs = vi.fn() + const setProjectTasks = vi.fn() + const { context, setMessage } = makeBrowserActionContext({ + setProjectTaskLogs, + setProjectTasks + }) + + loadSelectedProjectTasks(context) + + expect(loadProjectTasksMock).not.toHaveBeenCalled() + expect(setProjectTaskLogs).toHaveBeenCalledWith("") + expect(setProjectTasks).toHaveBeenCalledWith(null) + expect(setMessage).toHaveBeenLastCalledWith("No project selected.") + }) +}) diff --git a/packages/app/tests/docker-git/app-ready-create.test.ts b/packages/app/tests/docker-git/app-ready-create.test.ts index a3b71d49..18352a68 100644 --- a/packages/app/tests/docker-git/app-ready-create.test.ts +++ b/packages/app/tests/docker-git/app-ready-create.test.ts @@ -1,4 +1,4 @@ -import type { SetStateAction } from "react" +import type { Dispatch, SetStateAction } from "react" import { describe, expect, it, vi } from "vitest" import { @@ -6,50 +6,33 @@ import { createInitialFlowView, resolveCreateFlowSteps } from "../../src/docker-git/menu-create-shared.js" -import type { BrowserActionContext } from "../../src/web/actions.js" +import type { GithubAuthStatus } from "../../src/web/api.js" import { submitCreateView } from "../../src/web/app-ready-create.js" +import { makeBrowserActionContext } from "./browser-action-context-fixture.js" -const createSetter = () => vi.fn((_value: SetStateAction) => {}) +const validGithubStatus: GithubAuthStatus = { + summary: "valid", + tokens: [{ key: "default", label: "default", login: "octocat", status: "valid" }] +} -const createBrowserActionContext = (): BrowserActionContext => ({ - addTerminalSession: vi.fn(), - databaseConnectionInput: "", - databaseLabelInput: "", - githubStatus: { - summary: "ok", - tokens: [{ key: "GITHUB_TOKEN", label: "default", login: "octocat", status: "valid" }] - }, - portForwardInput: "", - reloadDashboard: vi.fn(), - selectedProjectId: null, - selectedProjectKey: null, - selectedProjectName: null, - setActionPrompt: vi.fn(), - setActiveScreen: createSetter(), - setAuthSnapshot: createSetter(), - setBusyLabel: createSetter(), - setDatabaseConnectionInput: createSetter(), - setDatabaseForwards: createSetter(), - setDatabaseLabelInput: createSetter(), - setDatabaseProfiles: createSetter(), - setDatabaseSession: createSetter(), - setGithubStatus: createSetter(), - setMessage: vi.fn(), - setOutput: createSetter(), - setPortForwardInput: createSetter(), - setPortForwards: createSetter(), - setProjectAuthSnapshot: createSetter(), - setProjectBrowser: createSetter(), - setProjectTaskLogs: createSetter(), - setProjectTasks: createSetter(), - setSelectedMenuIndex: createSetter(), - setSelectedProject: createSetter(), - setSelectedProjectId: createSetter() -}) +const createSetCreateViewSpy = () => { + const spy = vi.fn<(value: SetStateAction) => void>() + const setCreateView: Dispatch> = spy + return { setCreateView, spy } +} + +const requireCreateViewValue = ( + value: SetStateAction | undefined +): CreateFlowView => { + if (value === undefined || typeof value === "function") { + throw new Error("Expected CreateFlowView value.") + } + return value +} const submitCreateBuffer = (buffer: string) => { - const context = createBrowserActionContext() - const setCreateView = createSetter() + const { context } = makeBrowserActionContext({ githubStatus: validGithubStatus }) + const { setCreateView, spy: setCreateViewSpy } = createSetCreateViewSpy() submitCreateView({ context, @@ -59,19 +42,15 @@ const submitCreateBuffer = (buffer: string) => { setCreateView }) - return { context, setCreateView } + return { context, setCreateViewSpy } } describe("app-ready-create", () => { it("advances to the next create field on Enter for a repo URL", () => { - const { context, setCreateView } = submitCreateBuffer("https://github.com/org/repo/tree/feature-x --force") + const { context, setCreateViewSpy } = submitCreateBuffer("https://github.com/org/repo/tree/feature-x --force") - expect(setCreateView).toHaveBeenCalledTimes(1) - const nextViewAction = setCreateView.mock.calls[0]?.[0] - if (nextViewAction === undefined || typeof nextViewAction === "function") { - throw new Error("Expected create view object update") - } - const nextView = nextViewAction + expect(setCreateViewSpy).toHaveBeenCalledTimes(1) + const nextView = requireCreateViewValue(setCreateViewSpy.mock.calls[0]?.[0]) expect(nextView).toMatchObject({ step: 1, values: { @@ -92,9 +71,9 @@ describe("app-ready-create", () => { }) it("shows a parse error instead of submitting on invalid inline flags", () => { - const { context, setCreateView } = submitCreateBuffer("https://github.com/org/repo --bogus") + const { context, setCreateViewSpy } = submitCreateBuffer("https://github.com/org/repo --bogus") - expect(setCreateView).not.toHaveBeenCalled() + expect(setCreateViewSpy).not.toHaveBeenCalled() expect(context.setMessage).toHaveBeenCalledWith("Missing value for option: --bogus") }) }) diff --git a/packages/app/tests/docker-git/app-ready-shortcuts.test.ts b/packages/app/tests/docker-git/app-ready-shortcuts.test.ts index fb429b17..25e4bccb 100644 --- a/packages/app/tests/docker-git/app-ready-shortcuts.test.ts +++ b/packages/app/tests/docker-git/app-ready-shortcuts.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it, vi } from "vitest" +import { createInitialFlowView } from "../../src/docker-git/menu-create-shared.js" import type { DashboardData } from "../../src/web/api.js" +import { type BrowserShortcutArgs, dispatchBrowserShortcut } from "../../src/web/app-ready-shortcut-runtime.js" import { handleMenuNavigationKey, handleProjectNavigationKey, @@ -9,6 +11,7 @@ import { shouldRefreshProjectDetails, usesProjectPrimaryNavigation } from "../../src/web/app-ready-shortcuts.js" +import { makeBrowserActionContext } from "./browser-action-context-fixture.js" const makeEvent = (key: string): ShortcutKeyboardEvent => { const event: ShortcutKeyboardEvent = { @@ -17,6 +20,7 @@ const makeEvent = (key: string): ShortcutKeyboardEvent => { defaultPrevented: false, key, metaKey: false, + shiftKey: false, target: null, preventDefault: () => { event.defaultPrevented = true @@ -39,6 +43,49 @@ const runProjectNavigation = (projectNavigationArmed: boolean) => { return { handled, setSelectedProjectId } } +const storedTerminalSession: BrowserShortcutArgs["terminalSessions"][number] = { + closePath: "/projects/by-key/project-a-key/terminal-sessions/session-1", + exitMessage: "ended", + header: "SSH terminal: org/repo-a", + pendingDeleteMessage: "closed", + readyMessage: "ready", + session: { + createdAt: "2026-05-05T00:00:00.000Z", + id: "session-1", + projectId: "project-a", + sshCommand: "ssh dev@127.0.0.1", + status: "ready" + }, + subtitle: "ssh dev@127.0.0.1", + websocketPath: "/projects/by-key/project-a-key/terminal-sessions/session-1/ws" +} + +const makeShortcutArgs = ( + activeTerminalSessionId: string | null, + setSelectedProjectId: BrowserShortcutArgs["setSelectedProjectId"] +): BrowserShortcutArgs => { + const { context } = makeBrowserActionContext({ selectedProjectId: "project-a" }) + return { + activeScreen: { tag: "ProjectPicker" }, + activeTerminalSessionId, + actionPrompt: null, + context, + controllerCwd: "/repo", + createView: createInitialFlowView(""), + currentMenu: "Tasks", + dashboard, + projectBrowser: null, + projectsRoot: "/home/dev/.docker-git", + selectedProjectId: "project-a", + setActiveScreen: vi.fn(), + setCreateView: vi.fn(), + setProjectNavigationArmed: vi.fn(), + setSelectedMenuIndex: vi.fn(), + setSelectedProjectId, + terminalSessions: [storedTerminalSession] + } +} + const dashboard: DashboardData = { apiBaseUrl: "/api", health: { @@ -84,6 +131,7 @@ describe("app-ready-shortcuts", () => { expect(usesProjectPrimaryNavigation("Ports")).toBe(true) expect(usesProjectPrimaryNavigation("Databases")).toBe(true) expect(usesProjectPrimaryNavigation("Browser")).toBe(true) + expect(usesProjectPrimaryNavigation("Tasks")).toBe(true) expect(usesProjectPrimaryNavigation("ProjectAuth")).toBe(true) expect(usesProjectPrimaryNavigation("Logs")).toBe(true) expect(usesProjectPrimaryNavigation("Create")).toBe(false) @@ -155,9 +203,30 @@ describe("app-ready-shortcuts", () => { expect(shouldRefreshProjectDetails("Select", true, "project-a", null)).toBe(true) expect(shouldRefreshProjectDetails("Status", false, "project-a", null)).toBe(true) expect(shouldRefreshProjectDetails("Logs", false, "project-a", null)).toBe(true) + expect(shouldRefreshProjectDetails("Tasks", false, "project-a", null)).toBe(true) expect(shouldRefreshProjectDetails("Info", false, null, null)).toBe(false) }) + it("allows shortcuts when terminal sessions are stored but inactive", () => { + const event = makeEvent("ArrowDown") + const setSelectedProjectId = vi.fn() + const args = makeShortcutArgs(null, setSelectedProjectId) + + dispatchBrowserShortcut(event, args) + + expect(setSelectedProjectId).toHaveBeenCalledWith("project-b") + }) + + it("blocks global shortcuts while a terminal workspace is active", () => { + const event = makeEvent("ArrowDown") + const setSelectedProjectId = vi.fn() + const args = makeShortcutArgs("session-1", setSelectedProjectId) + + dispatchBrowserShortcut(event, args) + + expect(setSelectedProjectId).not.toHaveBeenCalled() + }) + it("skips selected project details when the same project is already loaded", () => { expect(shouldRefreshProjectDetails("Select", false, "project-a", { id: "project-a" })).toBe(false) expect(shouldRefreshProjectDetails("Info", false, "project-a", { id: "project-b" })).toBe(true) diff --git a/packages/app/tests/docker-git/app-ready-terminal-actions.test.ts b/packages/app/tests/docker-git/app-ready-terminal-actions.test.ts new file mode 100644 index 00000000..c56246e0 --- /dev/null +++ b/packages/app/tests/docker-git/app-ready-terminal-actions.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it, vi } from "vitest" + +import { openTerminalTaskManager } from "../../src/web/app-ready-terminal-actions.js" +import { browserMenuIndex } from "../../src/web/menu.js" +import { makeBrowserActionContext } from "./browser-action-context-fixture.js" + +const loadProjectTasksByIdMock = vi.hoisted(() => vi.fn()) + +vi.mock("../../src/web/actions.js", () => ({ + applyProjectById: vi.fn(), + applySelectedProject: vi.fn(), + attachProjectTerminalById: vi.fn(), + connectProjectById: vi.fn(), + loadProjectTasksById: loadProjectTasksByIdMock, + runApplyAllProjects: vi.fn() +})) + +vi.mock("../../src/web/api.js", () => ({ + deleteProjectTerminalSession: vi.fn() +})) + +describe("app-ready terminal actions", () => { + it("keeps terminal workspace active when opening task manager from an SSH session", () => { + const setActiveScreen = vi.fn() + const setProjectTaskLogs = vi.fn() + const setProjectTasks = vi.fn() + const setProjectTasksIncludeDefault = vi.fn() + const setSelectedMenuIndex = vi.fn() + const setSelectedProjectId = vi.fn() + const { context } = makeBrowserActionContext({ setActiveScreen }) + const state = { + setProjectTaskLogs, + setProjectTasks, + setProjectTasksIncludeDefault, + setSelectedMenuIndex, + setSelectedProjectId + } + + openTerminalTaskManager(context, state, "project-1") + + expect(setSelectedProjectId).toHaveBeenCalledWith("project-1") + expect(setSelectedMenuIndex).toHaveBeenCalledWith(browserMenuIndex("Tasks")) + expect(setProjectTasks).toHaveBeenCalledWith(null) + expect(setProjectTaskLogs).toHaveBeenCalledWith("") + expect(setProjectTasksIncludeDefault).toHaveBeenCalledWith(false) + expect(loadProjectTasksByIdMock).toHaveBeenCalledWith(context, "project-1", { includeDefault: false }) + expect(setActiveScreen).not.toHaveBeenCalled() + }) +}) diff --git a/packages/app/tests/docker-git/browser-action-context-fixture.ts b/packages/app/tests/docker-git/browser-action-context-fixture.ts index 735b6f5b..3fb9e772 100644 --- a/packages/app/tests/docker-git/browser-action-context-fixture.ts +++ b/packages/app/tests/docker-git/browser-action-context-fixture.ts @@ -27,6 +27,7 @@ export const makeBrowserActionContext = ( addTerminalSession: vi.fn(), githubStatus: null, portForwardInput: "", + projectTasksIncludeDefault: false, reloadDashboard, selectedProjectId: null, selectedProjectKey: null, @@ -49,6 +50,7 @@ export const makeBrowserActionContext = ( setProjectBrowser, setProjectTaskLogs: vi.fn(), setProjectTasks: vi.fn(), + setProjectTasksIncludeDefault: vi.fn(), setSelectedMenuIndex: vi.fn(), setSelectedProject: vi.fn(), setSelectedProjectId: vi.fn(),