Build App / compute-version (pull_request) Successful in 4s
Build App / build-macos (pull_request) Successful in 2m30s
Build App / build-linux (pull_request) Successful in 5m11s
Build App / build-windows (pull_request) Successful in 5m23s
Build App / create-tag (pull_request) Skipped
Build App / sync-to-github (pull_request) Skipped
Ten findings from the review of the previous commit, all applied. **The tab drag is now pointer events, not HTML5 drag-and-drop.** Two independent reasons, either one fatal. Tauri's `dragDropEnabled` blocks HTML5 drag inside the webview on Windows, and it cannot just be turned off — `TerminalView` needs Tauri's native drag-drop event, which is the only one that carries dropped *file paths*. And an HTML5 drag carries a `DataTransfer`: released over any text field in the app, the default handler types `term:<uuid>` into it, and in Config that is then saved with the project. Pointer events have neither problem, and the drag is measured from the tabs on screen rather than from the event target, so the marker and the drop agree even over the marker itself. Escape abandons a drag; a press under 4px stays a click; the click that ends a drag does not select. **`Ctrl+Shift+←/→` no longer swallows word-wise selection.** It is bound on `document` in the capture phase, so in any input — the rename field, Config, Settings — it was taking the OS's extend-selection chord *and* silently reordering the strip. Guarded by `inTextField()`, which excludes xterm's helper textarea: that is an input-method shim, and the terminal is where the shortcut matters most. **The pop-out's state is read from the window, never remembered.** The pane is unmounted whenever another Project Home sub-tab is selected, so "Keep on top" came back Off over a window still floating on top. `get_browser_view_popout_state` returns both facts from the window itself, and the change event carries them. `poppedOut` is tri-state: until the answer arrives the iframe is not mounted, because guessing "not popped out" is what flashes a second viewer onto the browser. Also: `popout::close` and the off-status emit in the supervisor are behind the same epoch guard as the deregistration above them, so a supervisor whose teardown outlives a restart can no longer destroy the *new* session's window; `close()` returns its `destroy()` error instead of logging it and reporting success, since the pane restores its iframe on success; the drop marker is `pointer-events-none` and is placed before the first *visible* tab at or past the slot, so it neither refuses a drop nor vanishes when a `tabOrder` entry renders nothing; and the "Keep on top" Toggle's accessible name now matches its visible text. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
126 lines
4.4 KiB
TypeScript
126 lines
4.4 KiB
TypeScript
import { useEffect } from "react";
|
|
import { useAppState, isTerminalTab, tabKeyId } from "../store/appState";
|
|
import { useTerminal } from "./useTerminal";
|
|
|
|
/**
|
|
* Whether the focus is in something the user is editing text in.
|
|
*
|
|
* xterm's hidden textarea is deliberately excluded: it is an input-method
|
|
* shim, not a field anyone edits word-wise, and the terminal is exactly where
|
|
* the tab shortcuts need to keep working.
|
|
*/
|
|
function inTextField(el: Element | null): boolean {
|
|
if (!el || el.closest(".xterm")) return false;
|
|
return (
|
|
el.tagName === "INPUT" ||
|
|
el.tagName === "TEXTAREA" ||
|
|
(el as HTMLElement).isContentEditable === true
|
|
);
|
|
}
|
|
|
|
/**
|
|
* App-level shortcuts. Registered on `document` in the *capture* phase so they
|
|
* win over xterm.js, which would otherwise forward them to the shell inside
|
|
* the container.
|
|
*
|
|
* Ctrl+T new Claude terminal for the current project
|
|
* Ctrl+Shift+W close the active tab
|
|
* Ctrl+Tab next tab (Ctrl+Shift+Tab for previous)
|
|
* Ctrl+1..9 jump to the nth tab
|
|
* Ctrl+Shift+←/→ move the active tab along the strip
|
|
*/
|
|
export function useKeyboardShortcuts() {
|
|
const { open: openTerminal, close: closeTerminal } = useTerminal();
|
|
|
|
useEffect(() => {
|
|
const onKeyDown = (e: KeyboardEvent) => {
|
|
if (!e.ctrlKey || e.altKey || e.metaKey) return;
|
|
|
|
const state = useAppState.getState();
|
|
|
|
// Ctrl+Tab / Ctrl+Shift+Tab — cycle tabs
|
|
if (e.key === "Tab") {
|
|
if (state.tabOrder.length === 0) return;
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
state.cycleTab(e.shiftKey ? -1 : 1);
|
|
return;
|
|
}
|
|
|
|
// Ctrl+Shift+W — close the active tab.
|
|
//
|
|
// Deliberately NOT plain Ctrl+W: that is readline's `kill-word` (delete
|
|
// the previous word), used constantly inside the terminal that is this
|
|
// app's centerpiece. Swallowing it globally would break word-erase in
|
|
// every shell and in Claude Code's own prompt.
|
|
if (e.shiftKey && (e.key === "w" || e.key === "W")) {
|
|
const key = state.activeTabKey;
|
|
if (!key) return;
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
if (isTerminalTab(key)) {
|
|
closeTerminal(tabKeyId(key)).catch((err) =>
|
|
console.error("Failed to close terminal:", err),
|
|
);
|
|
} else {
|
|
state.closeHomeTab(tabKeyId(key));
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Ctrl+Shift+←/→ — move the active tab, the keyboard route to what
|
|
// dragging a tab does. Shift is what keeps it clear of the terminal:
|
|
// Ctrl+←/→ is readline's word-wise cursor motion.
|
|
//
|
|
// In a text field this chord already means "extend the selection by a
|
|
// word", which is not ours to take: swallowing it would make word-wise
|
|
// selection impossible in every input in the app *and* silently reorder
|
|
// the strip each time someone tried it.
|
|
if (e.shiftKey && (e.key === "ArrowLeft" || e.key === "ArrowRight")) {
|
|
if (!state.activeTabKey || inTextField(document.activeElement)) return;
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
state.moveActiveTab(e.key === "ArrowLeft" ? -1 : 1);
|
|
return;
|
|
}
|
|
|
|
if (e.shiftKey) return;
|
|
|
|
// Ctrl+1..9 — jump to tab
|
|
if (/^[1-9]$/.test(e.key)) {
|
|
const index = Number(e.key) - 1;
|
|
if (index >= state.tabOrder.length) return;
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
state.focusTabIndex(index);
|
|
return;
|
|
}
|
|
|
|
// Ctrl+T — new Claude terminal for the current project
|
|
if (e.key === "t" || e.key === "T") {
|
|
// Prefer the project owning the focused tab, then the sidebar selection.
|
|
const projectId =
|
|
state.sessions.find((s) => s.id === state.activeSessionId)?.projectId ??
|
|
state.selectedProjectId ??
|
|
null;
|
|
const project = state.projects.find((p) => p.id === projectId);
|
|
if (!project || project.status !== "running") return;
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
openTerminal(project.id, project.name).catch((err) => {
|
|
state.pushToast({
|
|
kind: "error",
|
|
message: `Could not open a terminal for “${project.name}”`,
|
|
detail: String(err),
|
|
});
|
|
});
|
|
return;
|
|
}
|
|
|
|
};
|
|
|
|
document.addEventListener("keydown", onKeyDown, true);
|
|
return () => document.removeEventListener("keydown", onKeyDown, true);
|
|
}, [openTerminal, closeTerminal]);
|
|
}
|