Fix workspace accumulation, tab-close popover, scrollbars, drag ghost
- window_state.rs: persist only the main window's workspaces. The aggregator flattened every window's tabs into the saved file; main then adopted the whole blob on launch, so detached windows' ephemeral tabs (and Pane N drag-out artifacts) accumulated without bound. - TabStrip: portal the close-confirm popover to <body> with fixed, viewport-clamped positioning so the horizontally-scrolling strip can't clip it and it never runs off a window edge. - styles.css: make themed ::-webkit-scrollbar global, not just xterm viewport. - LeafPane: B1 drag-out ghost chip (portal, edge-pinned, orange detach state). - App.tsx: moveToNewWindow waits briefly for pane registration instead of failing instantly on an in-flight spawn/adopt. - gitignore cargo-test.lo*. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
bea6cf2977
commit
e6d0040021
9 changed files with 224 additions and 95 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -29,4 +29,4 @@ src-tauri/gen/
|
||||||
/shot*.png
|
/shot*.png
|
||||||
/tiletopia-window.png
|
/tiletopia-window.png
|
||||||
/tilescript.ps1
|
/tilescript.ps1
|
||||||
/cargo-test.log
|
/cargo-test.lo*
|
||||||
|
|
|
||||||
13
memory.md
13
memory.md
|
|
@ -115,8 +115,17 @@ Smoke test on Windows revealed bugs specific to detached (non-main) windows. Mai
|
||||||
|
|
||||||
- **B2–B5 (blank/dead detached windows) = the capability hypothesis, confirmed.** `src-tauri/capabilities/default.json` had `"windows": ["main"]`; detached labels are `pane-window-<micros>` (commands.rs:122) → matched nothing → every `invoke`/`listen` silently denied. Fix: `"windows": ["main", "pane-window-*"]`. Tauri 2 glob pattern works; one line cleared all four. (App-defined commands aren't individually permission-gated — they're available to any window the capability is *applied* to, i.e. listed in `windows`.)
|
- **B2–B5 (blank/dead detached windows) = the capability hypothesis, confirmed.** `src-tauri/capabilities/default.json` had `"windows": ["main"]`; detached labels are `pane-window-<micros>` (commands.rs:122) → matched nothing → every `invoke`/`listen` silently denied. Fix: `"windows": ["main", "pane-window-*"]`. Tauri 2 glob pattern works; one line cleared all four. (App-defined commands aren't individually permission-gated — they're available to any window the capability is *applied* to, i.e. listed in `windows`.)
|
||||||
- **Session-loss-on-adopt (surfaced after B2–B5 cleared) = destructive read × StrictMode.** Once IPC worked, drag-out still spawned a FRESH pty (new id, tab named "Default", status `alive` not `adopted`) instead of adopting. Cause: `take_pending_window_init` is a **destructive** backend read (`by_label.remove`); React StrictMode runs the mount effect twice in dev — pass 1 consumed the payload then bailed on the `cancelled` flag, pass 2 got `null` → fell back to `singletonEnvelope` (fresh "Default" + fresh spawn). The `cancelled`-flag pattern guards against *using* stale async results but cannot un-consume a destructive backend call. Fix: module-level memoized `consumePendingWindowInit()` in App.tsx so the take fires **exactly once per window** and both StrictMode passes share the payload. Dev-only symptom (prod StrictMode doesn't double-invoke effects) but fixed for robustness. **Lesson: any destructive/once-only backend read called from a mount effect must be memoized at module scope, not just guarded by `cancelled`.**
|
- **Session-loss-on-adopt (surfaced after B2–B5 cleared) = destructive read × StrictMode.** Once IPC worked, drag-out still spawned a FRESH pty (new id, tab named "Default", status `alive` not `adopted`) instead of adopting. Cause: `take_pending_window_init` is a **destructive** backend read (`by_label.remove`); React StrictMode runs the mount effect twice in dev — pass 1 consumed the payload then bailed on the `cancelled` flag, pass 2 got `null` → fell back to `singletonEnvelope` (fresh "Default" + fresh spawn). The `cancelled`-flag pattern guards against *using* stale async results but cannot un-consume a destructive backend call. Fix: module-level memoized `consumePendingWindowInit()` in App.tsx so the take fires **exactly once per window** and both StrictMode passes share the payload. Dev-only symptom (prod StrictMode doesn't double-invoke effects) but fixed for robustness. **Lesson: any destructive/once-only backend read called from a mount effect must be memoized at module scope, not just guarded by `cancelled`.**
|
||||||
- **Verified:** user confirmed adopt works (scrollback intact, same pane id, live input). `tsc -b` clean. B1 (drag ghost image) still deferred — cosmetic.
|
- **Verified:** user confirmed adopt works (scrollback intact, same pane id, live input). `tsc -b` clean.
|
||||||
- Committed together with the carried-over `use tauri::Manager;` lib.rs import.
|
- Committed (`bea6cf2`) together with the carried-over `use tauri::Manager;` lib.rs import.
|
||||||
|
|
||||||
|
**Follow-on fixes same session (commit after `bea6cf2`):**
|
||||||
|
|
||||||
|
- **B1 drag ghost (done).** Cursor-following chip via `createPortal` in LeafPane, `pointer-events:none` so it doesn't disturb the `elementFromPoint` drop-target hit-test. Turns orange "↗ New window" past the 60px edge margin. A webview **can't paint outside its own OS window**, so the chip is clamped to the viewport edge and flips to the cursor's inner side near right/bottom rather than vanishing — that's the best achievable; a ghost floating over the desktop is impossible. Hoisted `PANE_DRAG_OUT_MARGIN` + `isFarOutsideViewport()` to module scope so move-handler (preview) and up-handler (release) can't drift.
|
||||||
|
- **Drag-out "PTY not ready" (mitigated).** `moveToNewWindow` now `await waitForPaneRegistration(leafId, 5000)` instead of failing instantly when the id isn't registered yet — covers the race where a just-spawned/just-adopted pane is dragged before its async spawn round-trip registers. Resolves instantly if already registered.
|
||||||
|
- **Tab accumulation (root-caused + fixed).** The cross-window save aggregator (`window_state.rs::build_envelope`) concatenated EVERY window's workspaces into the saved file; on launch main loaded the whole blob and adopted it as its own tabs, then re-saved under "main" → unbounded growth (hit 14 tabs incl. `Pane 28`/`Pane 38` drag-out artifacts + piles of `Default` from pre-fix detached boots). Fix: `build_envelope` persists **only `MAIN_WINDOW_LABEL`'s** workspaces — detached windows are ephemeral by design (discarded on close), so they're now structurally unable to pollute the file. **Reset the corrupted `workspace.json`** (backed up to `workspace.json.corrupt-backup` in app config dir, then deleted; main reboots a clean single Default). Detached windows still `push_window_workspaces` (harmless; backend just ignores non-main for persistence).
|
||||||
|
- **Can't close tabs (fixed).** Tab strip is `overflow-x:auto`, which per spec coerces `overflow-y` to auto too → the in-strip absolutely-positioned close-confirm popover got clipped once enough tabs forced horizontal scroll. Fix: `createPortal` the confirm to `<body>`, `position:fixed`, fixed `width:300px` (matches `CONFIRM_POPOVER_WIDTH` const in TabStrip.tsx), right-aligned to the × button then **clamped into the viewport** so a left-side tab doesn't run off the left edge.
|
||||||
|
- **Native scrollbars (fixed).** `::-webkit-scrollbar` theming was scoped to `.xterm-viewport` only; made it global (`*::-webkit-scrollbar` + `* { scrollbar-width/color }`) so the tab strip / panels / menus match the dark theme.
|
||||||
|
- **Capability fix recap:** `default.json` `"windows": ["main", "pane-window-*"]` — the load-bearing fix for the whole detached-window feature (B2–B5). Confirmed: app-defined Tauri commands aren't individually permission-gated; they're available to any window the capability is *applied* to (listed in `windows`).
|
||||||
|
|
||||||
2. `pnpm tauri dev` — smoke test:
|
2. `pnpm tauri dev` — smoke test:
|
||||||
- Existing workspace loads as one tab named "Default" ✓ migrate
|
- Existing workspace loads as one tab named "Default" ✓ migrate
|
||||||
|
|
|
||||||
|
|
@ -66,29 +66,20 @@ impl WindowsState {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build the on-disk envelope by concatenating every window's
|
/// Build the on-disk envelope from ONLY the main window's workspaces.
|
||||||
/// workspaces in stable label order (main first when present, then
|
///
|
||||||
/// the rest sorted alphabetically by label — deterministic so the
|
/// Detached windows are ephemeral — their tabs are discarded on close
|
||||||
/// file diff stays stable across no-op saves).
|
/// (Chrome-style), and only the main window's tabs are meant to survive
|
||||||
|
/// a restart. Persisting every window's workspaces (the original design)
|
||||||
|
/// let detached windows' tabs — and the `Pane N` adopt-targets from
|
||||||
|
/// drag-out — leak into the saved file; on the next launch the main
|
||||||
|
/// window loaded the whole blob and adopted them all, so they
|
||||||
|
/// accumulated without bound. Keying the persisted set to the main label
|
||||||
|
/// makes detached state structurally unable to pollute it.
|
||||||
fn build_envelope(&self) -> Value {
|
fn build_envelope(&self) -> Value {
|
||||||
let map = self.per_window.lock();
|
let map = self.per_window.lock();
|
||||||
let mut keys: Vec<&String> = map.keys().collect();
|
let workspaces: Vec<Value> =
|
||||||
keys.sort_by(|a, b| {
|
map.get(MAIN_WINDOW_LABEL).cloned().unwrap_or_default();
|
||||||
// main first, then alpha
|
|
||||||
match (a.as_str(), b.as_str()) {
|
|
||||||
(MAIN_WINDOW_LABEL, _) => std::cmp::Ordering::Less,
|
|
||||||
(_, MAIN_WINDOW_LABEL) => std::cmp::Ordering::Greater,
|
|
||||||
(x, y) => x.cmp(y),
|
|
||||||
}
|
|
||||||
});
|
|
||||||
let mut workspaces: Vec<Value> = Vec::new();
|
|
||||||
for k in keys {
|
|
||||||
if let Some(list) = map.get(k) {
|
|
||||||
for w in list {
|
|
||||||
workspaces.push(w.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
"version": 2,
|
"version": 2,
|
||||||
"workspaces": workspaces,
|
"workspaces": workspaces,
|
||||||
|
|
|
||||||
15
src/App.tsx
15
src/App.tsx
|
|
@ -1005,10 +1005,19 @@ export default function App() {
|
||||||
notify("Cannot move — pane not found");
|
notify("Cannot move — pane not found");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const paneId = paneIdByLeafRef.current.get(leafId);
|
// The pane's id is registered only after its XtermPane finishes the
|
||||||
|
// async spawn/adopt round-trip. If the user drags out a pane that's
|
||||||
|
// still completing that (e.g. just after a shell-swap, or a pane in a
|
||||||
|
// freshly-detached window), wait for registration instead of failing
|
||||||
|
// outright. Resolves immediately if already registered.
|
||||||
|
let paneId = paneIdByLeafRef.current.get(leafId);
|
||||||
if (paneId == null) {
|
if (paneId == null) {
|
||||||
notify("Cannot move — PTY not ready yet");
|
try {
|
||||||
return;
|
paneId = await waitForPaneRegistration(leafId, 5000);
|
||||||
|
} catch {
|
||||||
|
notify("Cannot move — PTY not ready yet");
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -10,14 +10,11 @@
|
||||||
color: #aaa;
|
color: #aaa;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
overflow-y: visible;
|
|
||||||
min-height: 28px;
|
min-height: 28px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
/* Allow the inline confirm popover to spill over the bottom edge instead
|
/* The confirm popover is portalled to <body> (see TabStrip.tsx), so it is
|
||||||
of being clipped by overflow:hidden. overflow-y:visible alongside
|
not clipped by this strip's overflow. */
|
||||||
overflow-x:auto only works because confirm popovers position themselves
|
|
||||||
absolutely. */
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab-strip-item {
|
.tab-strip-item {
|
||||||
|
|
@ -113,15 +110,15 @@
|
||||||
border-color: #2a5a8c;
|
border-color: #2a5a8c;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Inline confirm popover anchored to the close button — spills below the
|
/* Confirm popover anchored to the close button. Portalled to <body> and
|
||||||
strip. Plain matte panel; reuses the existing app palette. */
|
positioned `fixed` (top/right set inline) so the horizontally-scrolling
|
||||||
|
tab strip — overflow-x:auto forces overflow-y:auto, which would clip an
|
||||||
|
in-strip popover — can't hide it. Plain matte panel; app palette. */
|
||||||
.tab-strip-confirm {
|
.tab-strip-confirm {
|
||||||
position: absolute;
|
position: fixed;
|
||||||
top: calc(100% + 4px);
|
z-index: 1000;
|
||||||
right: 0;
|
/* width must match CONFIRM_POPOVER_WIDTH in TabStrip.tsx (clamp math). */
|
||||||
z-index: 50;
|
width: 300px;
|
||||||
min-width: 260px;
|
|
||||||
max-width: 360px;
|
|
||||||
background: #1a1a1a;
|
background: #1a1a1a;
|
||||||
color: #e6e6e6;
|
color: #e6e6e6;
|
||||||
border: 1px solid #c98a1f;
|
border: 1px solid #c98a1f;
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,14 @@ import {
|
||||||
type KeyboardEvent as ReactKeyboardEvent,
|
type KeyboardEvent as ReactKeyboardEvent,
|
||||||
type MouseEvent as ReactMouseEvent,
|
type MouseEvent as ReactMouseEvent,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
import { walkLeaves, leafCount, type Workspace, type NodeId } from "../lib/layout/tree";
|
import { walkLeaves, leafCount, type Workspace, type NodeId } from "../lib/layout/tree";
|
||||||
import "./TabStrip.css";
|
import "./TabStrip.css";
|
||||||
|
|
||||||
|
/** Fixed width of the close-confirm popover — must match the `width` in
|
||||||
|
* TabStrip.css so the viewport-clamp math positions it accurately. */
|
||||||
|
const CONFIRM_POPOVER_WIDTH = 300;
|
||||||
|
|
||||||
interface TabStripProps {
|
interface TabStripProps {
|
||||||
workspaces: Workspace[];
|
workspaces: Workspace[];
|
||||||
currentWorkspaceId: NodeId | null;
|
currentWorkspaceId: NodeId | null;
|
||||||
|
|
@ -37,6 +42,14 @@ export default function TabStrip({
|
||||||
const [draft, setDraft] = useState("");
|
const [draft, setDraft] = useState("");
|
||||||
const editInputRef = useRef<HTMLInputElement>(null);
|
const editInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [confirmingId, setConfirmingId] = useState<NodeId | null>(null);
|
const [confirmingId, setConfirmingId] = useState<NodeId | null>(null);
|
||||||
|
// Anchor rect (the close button's) for the confirm popover. The popover is
|
||||||
|
// portalled to <body> with position:fixed because the tab strip scrolls
|
||||||
|
// horizontally (overflow-x:auto, which forces overflow-y to auto too),
|
||||||
|
// so an in-strip absolutely-positioned popover would be clipped.
|
||||||
|
const [confirmAnchor, setConfirmAnchor] = useState<{
|
||||||
|
top: number;
|
||||||
|
left: number;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
const startEdit = useCallback(
|
const startEdit = useCallback(
|
||||||
(id: NodeId, current: string, e: ReactMouseEvent) => {
|
(id: NodeId, current: string, e: ReactMouseEvent) => {
|
||||||
|
|
@ -106,6 +119,19 @@ export default function TabStrip({
|
||||||
onClose(id);
|
onClose(id);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||||
|
// Right-align the popover to the close button by default, then clamp
|
||||||
|
// both edges into the viewport so a left-side tab doesn't push it off
|
||||||
|
// the left edge (or a right-side tab off the right).
|
||||||
|
const pad = 8;
|
||||||
|
const left = Math.max(
|
||||||
|
pad,
|
||||||
|
Math.min(
|
||||||
|
rect.right - CONFIRM_POPOVER_WIDTH,
|
||||||
|
window.innerWidth - CONFIRM_POPOVER_WIDTH - pad,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
setConfirmAnchor({ top: rect.bottom + 4, left });
|
||||||
setConfirmingId(id);
|
setConfirmingId(id);
|
||||||
},
|
},
|
||||||
[workspaces, onClose],
|
[workspaces, onClose],
|
||||||
|
|
@ -127,7 +153,6 @@ export default function TabStrip({
|
||||||
{workspaces.map((w) => {
|
{workspaces.map((w) => {
|
||||||
const isActive = w.id === currentWorkspaceId;
|
const isActive = w.id === currentWorkspaceId;
|
||||||
const isEditing = editingId === w.id;
|
const isEditing = editingId === w.id;
|
||||||
const isConfirming = confirmingId === w.id;
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={w.id}
|
key={w.id}
|
||||||
|
|
@ -160,42 +185,6 @@ export default function TabStrip({
|
||||||
>
|
>
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
{isConfirming && (
|
|
||||||
<div
|
|
||||||
className="tab-strip-confirm"
|
|
||||||
role="dialog"
|
|
||||||
aria-label="Confirm close tab"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<div className="tab-strip-confirm-title">
|
|
||||||
Close "{confirmingWorkspace?.name}"?
|
|
||||||
</div>
|
|
||||||
<div className="tab-strip-confirm-body">
|
|
||||||
This will kill {confirmingPaneLabels.length} pane
|
|
||||||
{confirmingPaneLabels.length === 1 ? "" : "s"}:
|
|
||||||
<div className="tab-strip-confirm-labels">
|
|
||||||
{confirmingPaneLabels.join(", ")}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="tab-strip-confirm-actions">
|
|
||||||
<button
|
|
||||||
className="tab-strip-confirm-btn cancel"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setConfirmingId(null);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="tab-strip-confirm-btn destructive"
|
|
||||||
onClick={confirmClose}
|
|
||||||
>
|
|
||||||
Close tab
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
@ -207,6 +196,46 @@ export default function TabStrip({
|
||||||
>
|
>
|
||||||
+
|
+
|
||||||
</button>
|
</button>
|
||||||
|
{confirmingId != null &&
|
||||||
|
confirmAnchor &&
|
||||||
|
createPortal(
|
||||||
|
<div
|
||||||
|
className="tab-strip-confirm"
|
||||||
|
role="dialog"
|
||||||
|
aria-label="Confirm close tab"
|
||||||
|
style={{ top: confirmAnchor.top, left: confirmAnchor.left }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="tab-strip-confirm-title">
|
||||||
|
Close "{confirmingWorkspace?.name}"?
|
||||||
|
</div>
|
||||||
|
<div className="tab-strip-confirm-body">
|
||||||
|
This will kill {confirmingPaneLabels.length} pane
|
||||||
|
{confirmingPaneLabels.length === 1 ? "" : "s"}:
|
||||||
|
<div className="tab-strip-confirm-labels">
|
||||||
|
{confirmingPaneLabels.join(", ")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="tab-strip-confirm-actions">
|
||||||
|
<button
|
||||||
|
className="tab-strip-confirm-btn cancel"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setConfirmingId(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="tab-strip-confirm-btn destructive"
|
||||||
|
onClick={confirmClose}
|
||||||
|
>
|
||||||
|
Close tab
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -302,3 +302,40 @@
|
||||||
background: #2a5a8c;
|
background: #2a5a8c;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Cursor-following ghost shown while dragging a pane toolbar (B1). Rendered
|
||||||
|
into document.body via a portal, offset from the cursor, and pointer-events
|
||||||
|
none so it never disturbs the elementFromPoint hit-test that drives the
|
||||||
|
drop-target highlight. */
|
||||||
|
.pane-drag-ghost {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 1000;
|
||||||
|
/* transform set inline so the chip can flip to the cursor's inner side
|
||||||
|
near the right/bottom edges (keeps it visible while pinned to the edge). */
|
||||||
|
pointer-events: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
max-width: 320px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border: 1px solid #5a8cd8;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: rgba(20, 28, 40, 0.95);
|
||||||
|
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.5);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #cfe0f5;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.pane-drag-ghost-label {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.pane-drag-ghost.detach {
|
||||||
|
border-color: #e09838;
|
||||||
|
color: #ffd9a0;
|
||||||
|
}
|
||||||
|
.pane-drag-ghost-hint {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #ffb840;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import {
|
||||||
type MouseEvent,
|
type MouseEvent,
|
||||||
type PointerEvent as ReactPointerEvent,
|
type PointerEvent as ReactPointerEvent,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
import { type LeafNode, resolveFontSize, type LeafShellSpec } from "./tree";
|
import { type LeafNode, resolveFontSize, type LeafShellSpec } from "./tree";
|
||||||
import { useOrchestration } from "./orchestration";
|
import { useOrchestration } from "./orchestration";
|
||||||
import XtermPane from "../../components/XtermPane";
|
import XtermPane from "../../components/XtermPane";
|
||||||
|
|
@ -15,6 +16,19 @@ import "./LeafPane.css";
|
||||||
|
|
||||||
const IDLE_THRESHOLD_MS = 5000;
|
const IDLE_THRESHOLD_MS = 5000;
|
||||||
|
|
||||||
|
/** How far past a viewport edge the cursor must travel before a release is
|
||||||
|
* treated as "drag pane out of window" instead of "drop on empty space
|
||||||
|
* inside this window". Picked so an accidental release on the OS titlebar
|
||||||
|
* (~30px tall) stays inside the threshold. */
|
||||||
|
const PANE_DRAG_OUT_MARGIN = 60;
|
||||||
|
|
||||||
|
/** True when a point is past any viewport edge by PANE_DRAG_OUT_MARGIN. */
|
||||||
|
const isFarOutsideViewport = (x: number, y: number) =>
|
||||||
|
x < -PANE_DRAG_OUT_MARGIN ||
|
||||||
|
x > window.innerWidth + PANE_DRAG_OUT_MARGIN ||
|
||||||
|
y < -PANE_DRAG_OUT_MARGIN ||
|
||||||
|
y > window.innerHeight + PANE_DRAG_OUT_MARGIN;
|
||||||
|
|
||||||
export default function LeafPane({ leaf }: { leaf: LeafNode }) {
|
export default function LeafPane({ leaf }: { leaf: LeafNode }) {
|
||||||
const orch = useOrchestration();
|
const orch = useOrchestration();
|
||||||
const isActive = orch.activeLeafId === leaf.id;
|
const isActive = orch.activeLeafId === leaf.id;
|
||||||
|
|
@ -225,6 +239,17 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) {
|
||||||
const dragStartRef = useRef<{ x: number; y: number; armed: boolean; dragging: boolean } | null>(
|
const dragStartRef = useRef<{ x: number; y: number; armed: boolean; dragging: boolean } | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
|
// Cursor-following ghost shown while dragging the toolbar. `detach` flips
|
||||||
|
// true once the cursor is past the viewport edge by PANE_DRAG_OUT_MARGIN,
|
||||||
|
// mirroring the release condition in onToolbarPointerUp so the ghost
|
||||||
|
// previews what a release right now would do.
|
||||||
|
const [dragGhost, setDragGhost] = useState<{
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
detach: boolean;
|
||||||
|
flipX: boolean;
|
||||||
|
flipY: boolean;
|
||||||
|
} | null>(null);
|
||||||
const isDragSource = orch.dragSourceId === leaf.id;
|
const isDragSource = orch.dragSourceId === leaf.id;
|
||||||
const isDragTarget =
|
const isDragTarget =
|
||||||
orch.dragOverId === leaf.id && orch.dragSourceId !== leaf.id;
|
orch.dragOverId === leaf.id && orch.dragSourceId !== leaf.id;
|
||||||
|
|
@ -264,16 +289,27 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) {
|
||||||
const tEl = el?.closest("[data-leaf-id]");
|
const tEl = el?.closest("[data-leaf-id]");
|
||||||
const targetId = tEl?.getAttribute("data-leaf-id") ?? null;
|
const targetId = tEl?.getAttribute("data-leaf-id") ?? null;
|
||||||
orch.setHeaderDragOver(targetId);
|
orch.setHeaderDragOver(targetId);
|
||||||
|
// Move the cursor-following ghost (B1). It has pointer-events:none so
|
||||||
|
// it doesn't interfere with the elementFromPoint hit-test above.
|
||||||
|
// A webview can't paint outside its own OS window, so once the cursor
|
||||||
|
// crosses the edge we clamp the chip to the viewport (and flip it to
|
||||||
|
// the cursor's inner side near right/bottom) so it stays visible and
|
||||||
|
// its `detach` styling is what previews the release. `detach` itself
|
||||||
|
// is computed from the RAW cursor position so the preview is accurate.
|
||||||
|
const GHOST_PAD = 4;
|
||||||
|
const FLIP_X_ZONE = 180; // ~max chip width
|
||||||
|
const FLIP_Y_ZONE = 48;
|
||||||
|
setDragGhost({
|
||||||
|
x: Math.max(GHOST_PAD, Math.min(e.clientX, window.innerWidth - GHOST_PAD)),
|
||||||
|
y: Math.max(GHOST_PAD, Math.min(e.clientY, window.innerHeight - GHOST_PAD)),
|
||||||
|
detach: isFarOutsideViewport(e.clientX, e.clientY),
|
||||||
|
flipX: e.clientX > window.innerWidth - FLIP_X_ZONE,
|
||||||
|
flipY: e.clientY > window.innerHeight - FLIP_Y_ZONE,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
[orch.beginHeaderDrag, orch.setHeaderDragOver, leaf.id],
|
[orch.beginHeaderDrag, orch.setHeaderDragOver, leaf.id],
|
||||||
);
|
);
|
||||||
|
|
||||||
/** How far past a viewport edge the cursor must travel before a release
|
|
||||||
* is treated as "drag pane out of window" instead of "drop on empty
|
|
||||||
* space inside this window". Picked so an accidental release on the OS
|
|
||||||
* titlebar (~30px tall) stays inside the threshold. */
|
|
||||||
const PANE_DRAG_OUT_MARGIN = 60;
|
|
||||||
|
|
||||||
const onToolbarPointerUp = useCallback(
|
const onToolbarPointerUp = useCallback(
|
||||||
(e: ReactPointerEvent<HTMLDivElement>) => {
|
(e: ReactPointerEvent<HTMLDivElement>) => {
|
||||||
const st = dragStartRef.current;
|
const st = dragStartRef.current;
|
||||||
|
|
@ -281,14 +317,11 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) {
|
||||||
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
|
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
|
||||||
const wasDragging = st.dragging;
|
const wasDragging = st.dragging;
|
||||||
dragStartRef.current = null;
|
dragStartRef.current = null;
|
||||||
|
setDragGhost(null);
|
||||||
if (!wasDragging) return;
|
if (!wasDragging) return;
|
||||||
document.body.style.cursor = "";
|
document.body.style.cursor = "";
|
||||||
|
|
||||||
const releasedFarOutside =
|
const releasedFarOutside = isFarOutsideViewport(e.clientX, e.clientY);
|
||||||
e.clientX < -PANE_DRAG_OUT_MARGIN ||
|
|
||||||
e.clientX > window.innerWidth + PANE_DRAG_OUT_MARGIN ||
|
|
||||||
e.clientY < -PANE_DRAG_OUT_MARGIN ||
|
|
||||||
e.clientY > window.innerHeight + PANE_DRAG_OUT_MARGIN;
|
|
||||||
|
|
||||||
if (releasedFarOutside) {
|
if (releasedFarOutside) {
|
||||||
// Cancel any in-flight swap state without committing, then pop
|
// Cancel any in-flight swap state without committing, then pop
|
||||||
|
|
@ -310,6 +343,7 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) {
|
||||||
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
|
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
|
||||||
const wasDragging = st.dragging;
|
const wasDragging = st.dragging;
|
||||||
dragStartRef.current = null;
|
dragStartRef.current = null;
|
||||||
|
setDragGhost(null);
|
||||||
if (wasDragging) {
|
if (wasDragging) {
|
||||||
document.body.style.cursor = "";
|
document.body.style.cursor = "";
|
||||||
orch.endHeaderDrag(false);
|
orch.endHeaderDrag(false);
|
||||||
|
|
@ -579,6 +613,26 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{dragGhost &&
|
||||||
|
createPortal(
|
||||||
|
<div
|
||||||
|
className={`pane-drag-ghost${dragGhost.detach ? " detach" : ""}`}
|
||||||
|
style={{
|
||||||
|
left: dragGhost.x,
|
||||||
|
top: dragGhost.y,
|
||||||
|
transform: `translate(${
|
||||||
|
dragGhost.flipX ? "calc(-100% - 12px)" : "12px"
|
||||||
|
}, ${dragGhost.flipY ? "calc(-100% - 12px)" : "12px"})`,
|
||||||
|
}}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<span className="pane-drag-ghost-label">{labelText}</span>
|
||||||
|
{dragGhost.detach && (
|
||||||
|
<span className="pane-drag-ghost-hint">↗ New window</span>
|
||||||
|
)}
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -38,28 +38,31 @@ body {
|
||||||
.xterm { height: 100%; }
|
.xterm { height: 100%; }
|
||||||
.xterm-viewport { background: #0c0c0c !important; }
|
.xterm-viewport { background: #0c0c0c !important; }
|
||||||
|
|
||||||
/* Themed scrollbars — Chromium pseudo-elements (WebView2 supports these). */
|
/* Themed scrollbars — Chromium pseudo-elements (WebView2 supports these).
|
||||||
.xterm-viewport::-webkit-scrollbar {
|
Applied globally so every scroll container (tab strip, panels, menus,
|
||||||
|
xterm viewport) matches the dark theme instead of falling back to the
|
||||||
|
native WebView2 scrollbar. */
|
||||||
|
*::-webkit-scrollbar {
|
||||||
width: 8px;
|
width: 8px;
|
||||||
height: 8px;
|
height: 8px;
|
||||||
}
|
}
|
||||||
.xterm-viewport::-webkit-scrollbar-track {
|
*::-webkit-scrollbar-track {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
.xterm-viewport::-webkit-scrollbar-thumb {
|
*::-webkit-scrollbar-thumb {
|
||||||
background: #2a2a2a;
|
background: #2a2a2a;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
border: 1px solid #1a1a1a;
|
border: 1px solid #1a1a1a;
|
||||||
}
|
}
|
||||||
.xterm-viewport::-webkit-scrollbar-thumb:hover {
|
*::-webkit-scrollbar-thumb:hover {
|
||||||
background: #3a3a3a;
|
background: #3a3a3a;
|
||||||
}
|
}
|
||||||
.xterm-viewport::-webkit-scrollbar-corner {
|
*::-webkit-scrollbar-corner {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
/* Firefox fallback (and the new spec) — not strictly needed in WebView2
|
/* Firefox fallback (and the new spec) — not strictly needed in WebView2
|
||||||
but free-and-correct. */
|
but free-and-correct. */
|
||||||
.xterm-viewport {
|
* {
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
scrollbar-color: #2a2a2a transparent;
|
scrollbar-color: #2a2a2a transparent;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue