collin/browser-terminal-extension · 81bf09bf
Add tab pinning, pane title publishing, and terminal URL linkification
Collin Richards · 2026-08-18 06:41 UTC · 81bf09bfd2499cba85f32e92061d75bf4d9e6232 · parent a71dc952 · browse files
modifiedREADME.md+171 −2
| ⋯ 245 unchanged lines | |||
| 246 | 246 | window they point at closes. They are per-browser-profile, not shared with | |
| 247 | 247 | anyone else attached to the session. | |
| 248 | 248 | ||
| 249 | + | ## Pinning a session to a browser tab | |
| 250 | + | ||
| 251 | + | Right-clicking a session or a window tab also offers to **pin** it to the | |
| 252 | + | browser tab you are on. After that, switching to that browser tab switches the | |
| 253 | + | terminal: the tab you keep the app in brings up the session you run the app | |
| 254 | + | from, and the docs tab beside it brings back whatever you had there. | |
| 255 | + | ||
| 256 | + | This is a different thing from the **Pin** in the paragraph above, which is | |
| 257 | + | about where a tab sits in the strip. This one is about which browser tab brings | |
| 258 | + | it up. They are independent, and a window can have both. | |
| 259 | + | ||
| 260 | + | Nothing is focused when it does: the terminal moves underneath, and the caret | |
| 261 | + | stays on the page. | |
| 262 | + | ||
| 263 | + | ### It is a loan, not a move | |
| 264 | + | ||
| 265 | + | A pin *borrows* the terminal. Leaving for a browser tab with no pin of its own | |
| 266 | + | puts it back where it was before the pinned tab took it, so flicking between a | |
| 267 | + | pinned tab and an unpinned one flicks the terminal between two places rather | |
| 268 | + | than stranding it on the pinned one. Without that, a single glance at a pinned | |
| 269 | + | tab would relocate the terminal permanently. | |
| 270 | + | ||
| 271 | + | Three rules keep that from being annoying: | |
| 272 | + | ||
| 273 | + | - Only the first pin in a run records a return. Pinned tab to pinned tab to | |
| 274 | + | unpinned goes back to where the run *started*, not to the middle of it — the | |
| 275 | + | middle was never somewhere you chose to be. | |
| 276 | + | - A pin that had nothing to do owes nothing. Landing on a tab pinned to where | |
| 277 | + | you already are records no return, so leaving it moves nothing. | |
| 278 | + | - A terminal you have since moved by hand is left alone. The return is an undo | |
| 279 | + | of a move this code made; once you have steered somewhere yourself there is | |
| 280 | + | nothing to undo, and dragging you back would be the panel overruling you. | |
| 281 | + | ||
| 282 | + | Following also waits about a sixth of a second before it acts, so Ctrl-Tabbing | |
| 283 | + | through six tabs is one move at the end rather than six on the way. | |
| 284 | + | ||
| 285 | + | A pin can key on two things: | |
| 286 | + | ||
| 287 | + | - **the site**, e.g. `localhost:26210`. Any tab on that origin matches, and the | |
| 288 | + | pin outlives the tab, the window and the browser, because an origin is a name. | |
| 289 | + | This is the one to reach for. | |
| 290 | + | - **that exact browser tab**, by the id Chrome gave it. Survives nothing, and | |
| 291 | + | exists for what an origin cannot express: two tabs on the same site pointing | |
| 292 | + | at different sessions, or a page whose URL says nothing. | |
| 293 | + | ||
| 294 | + | A tab pin wins over a site pin, being the more specific statement. | |
| 295 | + | ||
| 296 | + | Pinning the **session** rather than one of its windows is usually what you want: | |
| 297 | + | it means "this tab brings up that project, wherever I left it", and it keeps | |
| 298 | + | working when you close and reopen the window the dev server was in. A window pin | |
| 299 | + | whose window has since been killed falls back to its session rather than going | |
| 300 | + | quietly dead. | |
| 301 | + | ||
| 302 | + | A small accent dot marks whichever row the tab on screen points at, so the panel | |
| 303 | + | moving on its own always has a visible reason. The tooltip says which rule | |
| 304 | + | answered. | |
| 305 | + | ||
| 306 | + | ### And back the other way | |
| 307 | + | ||
| 308 | + | The same pin also reads backwards, so moving the terminal brings the browser | |
| 309 | + | along: switch to that session and the tab you pinned to it comes up. Anything | |
| 310 | + | that moves the terminal counts — a click on a session tab in the panel, a | |
| 311 | + | `prefix n` typed into the pane, another client switching a session this one is | |
| 312 | + | watching — because by the time it reaches the panel it is one status frame | |
| 313 | + | either way. | |
| 314 | + | ||
| 315 | + | This is the half that touches the browser, so it is deliberately timid, and it | |
| 316 | + | has its own checkbox (**and switch tabs back**) for turning off without giving | |
| 317 | + | up the forward direction: | |
| 318 | + | ||
| 319 | + | - It only ever **activates a tab that is already open**, in the panel's own | |
| 320 | + | browser window. It does not create tabs, does not focus the browser, does not | |
| 321 | + | raise a window and does not reach into another window. The worst it can do is | |
| 322 | + | show you a tab you already had. | |
| 323 | + | - It does nothing when the tab already showing satisfies the pin. That is also | |
| 324 | + | what stops the two directions chasing each other: the forward one will not | |
| 325 | + | move a terminal that is already where the tab points, and this one will not | |
| 326 | + | move a browser that is already on a tab pointing here, so whichever fires | |
| 327 | + | second finds its work done. | |
| 328 | + | - Two tabs can answer to one pin — two tabs on the pinned origin — and the tie | |
| 329 | + | goes first to the more specific rule and then to the one you looked at more | |
| 330 | + | recently. | |
| 331 | + | - It waits the same sixth of a second, so holding `prefix n` through six windows | |
| 332 | + | moves the browser once at the end rather than flicking it through five tabs on | |
| 333 | + | the way. | |
| 334 | + | - The loan being handed back does not count as a move. Leaving a pinned tab puts | |
| 335 | + | the terminal back where it was borrowed from, and if some third tab happens to | |
| 336 | + | be pinned to *that* place this stays out of it rather than chasing it and | |
| 337 | + | undoing the tab switch you just made by hand. | |
| 338 | + | ||
| 339 | + | It reads the pins you already have rather than a second set of its own, which | |
| 340 | + | means a veto keeps vetoing and a tab pin keeps outranking a site pin from this | |
| 341 | + | end too. There is nothing extra to set up: pin a session to a tab and both | |
| 342 | + | directions light up together. | |
| 343 | + | ||
| 344 | + | ### Guessing from the port | |
| 345 | + | ||
| 346 | + | Under the explicit pins sits a guess, on by default and switchable in settings: | |
| 347 | + | a tab on a `localhost` port that `devport` would hand to a project we have a | |
| 348 | + | session in is treated as pinned to that session, without anyone saying so. | |
| 349 | + | ||
| 350 | + | devport gives a project a stable block of ten ports from a checksum of its | |
| 351 | + | directory name, so nothing has to be registered anywhere — and that mapping is | |
| 352 | + | reproducible offline. `extension/lib/devport.js` reimplements it (POSIX `cksum` | |
| 353 | + | and all) and hashes the working directories tmux already reports, which is the | |
| 354 | + | same answer `devport -r` gives without a subprocess, a filesystem walk, or | |
| 355 | + | devport being installed. | |
| 356 | + | ||
| 357 | + | The guess is deliberately timid. Local hosts only, in-range ports only, and an | |
| 358 | + | ambiguous match — two sessions in one block, which collisions make possible — | |
| 359 | + | resolves to nothing rather than a coin toss. Unpinning a guessed match records a | |
| 360 | + | veto against that origin, which is the only way to say "no, not this one" to a | |
| 361 | + | rule that would otherwise keep re-deriving itself. | |
| 362 | + | ||
| 363 | + | Everything here is panel state: it lives in extension storage, it is | |
| 364 | + | per-browser-profile, and none of it reaches the daemon, which has never heard of | |
| 365 | + | a browser tab. It also only works while the panel is open — the panel is what | |
| 366 | + | watches the tabs. | |
| 367 | + | ||
| 249 | 368 | ## How the daemon talks to tmux | |
| 250 | 369 | ||
| 251 | 370 | The interactive client in the pty is busy being a terminal, so the daemon | |
| ⋯ 153 unchanged lines | |||
| 405 | 524 | several times a second; stepping on events costs nothing and moves the glyph | |
| 406 | 525 | exactly when Claude crosses a tool boundary. | |
| 407 | 526 | ||
| 408 | - | The only command this issues is `set-option -w @tb_claude`. It cannot rename a | |
| 409 | - | window, change a layout, or send a key. | |
| 527 | + | The only command this issues is `set-option -w @tb_claude`. It cannot change a | |
| 528 | + | layout or send a key. | |
| 529 | + | ||
| 530 | + | ### Titles from the last prompt | |
| 531 | + | ||
| 532 | + | Two panes running Claude in the same repo are indistinguishable by anything tmux | |
| 533 | + | knows about them: `#{pane_current_command}` is `node` in both and the cwd is the | |
| 534 | + | same. What tells them apart is what you asked each one to do, and | |
| 535 | + | `UserPromptSubmit` hands the hook exactly that. | |
| 536 | + | ||
| 537 | + | So the hook flattens the prompt to one line — whitespace collapsed, control | |
| 538 | + | characters dropped, cut to 80 characters at a word — stores it on the session's | |
| 539 | + | record, and writes it to the pane title: | |
| 540 | + | ||
| 541 | + | ```sh | |
| 542 | + | tmux select-pane -T "fix the flaky pty test" | |
| 543 | + | ``` | |
| 544 | + | ||
| 545 | + | Pane title rather than window name, deliberately. `rename-window` would turn | |
| 546 | + | automatic renaming off for that window for good and overwrite a name you or your | |
| 547 | + | shell chose; the pane title is a field almost nothing else uses, and a window | |
| 548 | + | that wants to follow it opts in through `automatic-rename-format` — which also | |
| 549 | + | means the *active* pane is what names the window, for free: | |
| 550 | + | ||
| 551 | + | ```tmux | |
| 552 | + | set -g automatic-rename-format \ | |
| 553 | + | "#{?#{==:#{pane_title},#{host}},#{pane_current_command},#{=/40/…:pane_title}}" | |
| 554 | + | ``` | |
| 410 | 555 | ||
| 556 | + | Both fallbacks are load bearing. tmux seeds a pane's title with the hostname, so | |
| 557 | + | "has no title of its own" has to be tested for rather than assumed empty, and a | |
| 558 | + | pane that never ran Claude keeps the name tmux would have given it anyway. | |
| 559 | + | Substitute your own format for `#{pane_current_command}` if you had one. | |
| 560 | + | ||
| 561 | + | `select-pane -T` expands its argument as a tmux format, so `#` in a prompt is | |
| 562 | + | doubled on the way in and a prompt containing `#{...}` or `#[fg=red]` is inert. | |
| 563 | + | ||
| 564 | + | The title is only ever set, never cleared: the last prompt is still the most | |
| 565 | + | useful thing to say about a pane that just finished running it, and a shell that | |
| 566 | + | emits an OSC title on each prompt takes the pane back on its own. The same | |
| 567 | + | string is what the sidebar shows for a session that has no name yet — Claude | |
| 568 | + | only names a session once it has thought of one, and until then "what you asked" | |
| 569 | + | beats "idle". | |
| 570 | + | ||
| 411 | 571 | ## Opening and focusing the terminal | |
| 412 | 572 | ||
| 413 | 573 | **Alt+Shift+T** is one key for the whole cycle, and what it does depends on | |
| ⋯ 61 unchanged lines | |||
| 475 | 635 | actually captured; if the capture fails, only the selector is inserted and the | |
| 476 | 636 | reason is logged. | |
| 477 | 637 | ||
| 638 | + | **Where the keyboard ends up.** In the page, where you just clicked — the | |
| 639 | + | selector is typed into a terminal you have to click into before you can type | |
| 640 | + | there yourself. Moving the keyboard into the panel was tried and does not work: | |
| 641 | + | the only way to focus a side panel is for Chrome to *open* it, which means | |
| 642 | + | closing and reopening it (see *Opening and focusing the terminal*), and even | |
| 643 | + | driven from the pick click's own user activation Chrome reopens the panel | |
| 644 | + | without handing it the keyboard. All that bought was a flicker, so the pick | |
| 645 | + | leaves the focus where it finds it. | |
| 646 | + | ||
| 478 | 647 | The panel still shows the element afterwards: CSS selector, XPath, `id`, test | |
| 479 | 648 | id, text, or `href`. **copy** puts the selected one on the clipboard, and | |
| 480 | 649 | **insert** re-types it (text only — the screenshot is already on the clipboard | |
| ⋯ 194 unchanged lines | |||
modifiedTODO.md+14 −2
| 1 | + | - [ ] be able to talk to claude about anything on the page | |
| 2 | + | - [ ] remote host tmux session | |
| 1 | 3 | - [ ] Idea: pull off a tab out side of the browser and it puts it within your default terminal emulator | |
| 2 | 4 | - [ ] TODO: get working well on unconfigured tmux | |
| 3 | 5 | - [ ] TODO: investigate chrome wterm and vercel wterm | |
| 4 | - | - [ ] Idea: pinned tab / session mode | |
| 6 | + | - [x] Idea: pinned tab / session mode | |
| 7 | + | - right-click a session or window tab to pin it to the browser tab you are | |
| 8 | + | on, by site or by that exact tab; localhost ports are matched to sessions | |
| 9 | + | by devport's hash when nothing is pinned. See README, "Pinning a session | |
| 10 | + | to a browser tab". | |
| 11 | + | - the same pins read backwards too: moving the terminal activates the tab | |
| 12 | + | pinned to where it landed. Own checkbox, "and switch tabs back". | |
| 5 | 13 | - [ ] send to claude should auto name window using llm | |
| 6 | 14 | - [ ] typing cd in omnibar should have a ui for selecting dir | |
| 7 | 15 | - this will change the directory of the current tmux session | |
| 8 | - | - [ ] be able to talk to claude about anything on the page | |
| 9 | 16 | - [ ] I notice that ctrl-shift-l focuses our omni bar properly when used within our extension, but when the page is focused, using ctrl-shift-l doesn't properly select the text in our omnibar | |
| 17 | + | - we kinda got it working with a werid workaround hack which closes then re-opens | |
| 18 | + | - this is okay, but janky | |
| 19 | + | ||
| 20 | + | - [ ] does neovim have a way to probe the terminal's background color | |
| 21 | + | - [ ] use claude in chrome with a browser on a different machine |
modifiedbuild.sh+1 −1
| ⋯ 6 unchanged lines | |||
| 7 | 7 | out="dist/$browser" | |
| 8 | 8 | rm -rf "$out"; mkdir -p "$out" | |
| 9 | 9 | cp extension/sidebar.html extension/sidebar.js extension/sidebar.css extension/picker.js "$out/" | |
| 10 | - | mkdir -p "$out/lib" && cp extension/lib/sanitize.js extension/lib/theme.js extension/lib/shot.js extension/lib/split.js "$out/lib/" | |
| 10 | + | mkdir -p "$out/lib" && cp extension/lib/sanitize.js extension/lib/theme.js extension/lib/shot.js extension/lib/split.js extension/lib/devport.js extension/lib/tabpin.js "$out/lib/" | |
| 11 | 11 | cp -r extension/vendor extension/icons "$out/" | |
| 12 | 12 | cp "extension/manifest.$browser.json" "$out/manifest.json" | |
| 13 | 13 | cp extension/sw.js "$out/" # both browsers now run a background script | |
| ⋯ 11 unchanged lines | |||
modifieddaemon/src/agents.rs+119 −0
| ⋯ 62 unchanged lines | |||
| 63 | 63 | /// Session title, when Claude has one. | |
| 64 | 64 | #[serde(default, skip_serializing_if = "Option::is_none")] | |
| 65 | 65 | pub name: Option<String>, | |
| 66 | + | /// One line of the last thing the user asked, from `UserPromptSubmit`. It | |
| 67 | + | /// is what [`crate::pane_title`] writes to the pane title, and what the | |
| 68 | + | /// sidebar falls back to when a session has no name yet — which is most of | |
| 69 | + | /// them, since a name only arrives once Claude has thought of one. | |
| 70 | + | #[serde(default, skip_serializing_if = "Option::is_none")] | |
| 71 | + | pub prompt: Option<String>, | |
| 66 | 72 | /// Which asterisk frame this session's glyph is on. Advanced once per hook | |
| 67 | 73 | /// event so the tmux status line has something to animate with — see | |
| 68 | 74 | /// [`crate::window_status`]. The sidebar runs its own timer and ignores it. | |
| ⋯ 113 unchanged lines | |||
| 182 | 188 | _ => None, | |
| 183 | 189 | }, | |
| 184 | 190 | name: str_field("session_name").or_else(|| carry(|r| r.name.clone())), | |
| 191 | + | // Only `UserPromptSubmit` carries one; every other event keeps the one | |
| 192 | + | // already on disk, so the title stays put for the whole turn instead of | |
| 193 | + | // blinking out on the first tool call. | |
| 194 | + | prompt: str_field("prompt") | |
| 195 | + | .as_deref() | |
| 196 | + | .and_then(summarize) | |
| 197 | + | .or_else(|| carry(|r| r.prompt.clone())), | |
| 185 | 198 | // Wrapping, and the glyph indexes into a six-frame cycle: the absolute | |
| 186 | 199 | // count is never read, only its position in the loop. | |
| 187 | 200 | frame: previous.as_ref().map_or(0, |p| p.frame.wrapping_add(1)), | |
| ⋯ 15 unchanged lines | |||
| 203 | 216 | fs::rename(&tmp, path) | |
| 204 | 217 | } | |
| 205 | 218 | ||
| 219 | + | /// How much of a prompt is kept. Long enough that a sentence usually survives | |
| 220 | + | /// whole, short enough that a record, a pane title and a status line entry are | |
| 221 | + | /// all bounded. Anything that has to be shorter still — a narrow status line, | |
| 222 | + | /// a sidebar tab — trims what it renders rather than what is stored. | |
| 223 | + | const PROMPT_MAX: usize = 80; | |
| 224 | + | ||
| 225 | + | /// A prompt as a single line of title. | |
| 226 | + | /// | |
| 227 | + | /// Prompts are pasted stack traces and multi-paragraph essays as often as they | |
| 228 | + | /// are one sentence, and they reach a pane title, so they are flattened to one | |
| 229 | + | /// line, stripped of control characters, and cut. `None` for a prompt with | |
| 230 | + | /// nothing printable in it — better to keep the previous title than to blank it. | |
| 231 | + | pub fn summarize(prompt: &str) -> Option<String> { | |
| 232 | + | let mut out = String::new(); | |
| 233 | + | let mut space = false; | |
| 234 | + | for c in prompt.chars() { | |
| 235 | + | if c.is_whitespace() { | |
| 236 | + | space = !out.is_empty(); | |
| 237 | + | continue; | |
| 238 | + | } | |
| 239 | + | // Control characters, and the escape sequences built from them, would | |
| 240 | + | // be written straight into a terminal's title. | |
| 241 | + | if c.is_control() { | |
| 242 | + | continue; | |
| 243 | + | } | |
| 244 | + | if space { | |
| 245 | + | out.push(' '); | |
| 246 | + | space = false; | |
| 247 | + | } | |
| 248 | + | out.push(c); | |
| 249 | + | // One past the limit, so the caller below can tell "exactly full" from | |
| 250 | + | // "there was more". | |
| 251 | + | if out.chars().count() > PROMPT_MAX { | |
| 252 | + | break; | |
| 253 | + | } | |
| 254 | + | } | |
| 255 | + | if out.is_empty() { | |
| 256 | + | return None; | |
| 257 | + | } | |
| 258 | + | if out.chars().count() <= PROMPT_MAX { | |
| 259 | + | return Some(out); | |
| 260 | + | } | |
| 261 | + | let mut cut: String = out.chars().take(PROMPT_MAX).collect(); | |
| 262 | + | // Prefer a word boundary, but only a late one: cutting "refactor the | |
| 263 | + | // authentication" back to "refactor" loses more than the ragged edge costs. | |
| 264 | + | let floor = cut.len() * 2 / 3; | |
| 265 | + | if let Some(i) = cut.rfind(' ').filter(|i| *i >= floor) { | |
| 266 | + | cut.truncate(i); | |
| 267 | + | } | |
| 268 | + | cut.push('…'); | |
| 269 | + | Some(cut) | |
| 270 | + | } | |
| 271 | + | ||
| 206 | 272 | /// Session ids come from Claude, but they end up in a filename, so treat them | |
| 207 | 273 | /// as untrusted: no separators, no `..`, no surprises. | |
| 208 | 274 | fn sanitize_id(id: &str) -> String { | |
| ⋯ 147 unchanged lines | |||
| 356 | 422 | }); | |
| 357 | 423 | } | |
| 358 | 424 | ||
| 425 | + | /// The title has to survive the whole turn: the prompt arrives once and | |
| 426 | + | /// every event after it is a tool call that knows nothing about it. | |
| 427 | + | #[test] | |
| 428 | + | fn the_prompt_outlives_the_event_that_carried_it() { | |
| 429 | + | with_temp_dir(|| { | |
| 430 | + | let r = event(serde_json::json!({ | |
| 431 | + | "session_id": "a", "hook_event_name": "UserPromptSubmit", | |
| 432 | + | "cwd": "/tmp/p", "prompt": "fix the flaky pty test", | |
| 433 | + | })) | |
| 434 | + | .unwrap(); | |
| 435 | + | assert_eq!(r.prompt.as_deref(), Some("fix the flaky pty test")); | |
| 436 | + | ||
| 437 | + | let r = event(serde_json::json!({ | |
| 438 | + | "session_id": "a", "hook_event_name": "PreToolUse", "tool_name": "Bash", | |
| 439 | + | })) | |
| 440 | + | .unwrap(); | |
| 441 | + | assert_eq!(r.prompt.as_deref(), Some("fix the flaky pty test")); | |
| 442 | + | }); | |
| 443 | + | } | |
| 444 | + | ||
| 445 | + | #[test] | |
| 446 | + | fn a_prompt_becomes_one_line_of_title() { | |
| 447 | + | assert_eq!( | |
| 448 | + | summarize(" fix the\n\tflaky test ").as_deref(), | |
| 449 | + | Some("fix the flaky test") | |
| 450 | + | ); | |
| 451 | + | // Escape sequences would otherwise reach a terminal's title. | |
| 452 | + | assert_eq!( | |
| 453 | + | summarize("red \x1b[31mtext").as_deref(), | |
| 454 | + | Some("red [31mtext") | |
| 455 | + | ); | |
| 456 | + | assert_eq!(summarize(" \n ").as_deref(), None); | |
| 457 | + | } | |
| 458 | + | ||
| 459 | + | #[test] | |
| 460 | + | fn a_long_prompt_is_cut_at_a_word() { | |
| 461 | + | let long = "please refactor the websocket handshake so that pairing happens \ | |
| 462 | + | before the upgrade rather than after it"; | |
| 463 | + | let cut = summarize(long).unwrap(); | |
| 464 | + | assert!(cut.ends_with('…'), "{cut}"); | |
| 465 | + | assert!(cut.chars().count() <= PROMPT_MAX + 1, "{cut}"); | |
| 466 | + | assert!(!cut.contains(" ")); | |
| 467 | + | // Cut at a boundary, so no half word before the ellipsis. | |
| 468 | + | let body = cut.trim_end_matches('…'); | |
| 469 | + | assert!(long.starts_with(body), "{cut}"); | |
| 470 | + | assert!(long[body.len()..].starts_with(' '), "{cut}"); | |
| 471 | + | ||
| 472 | + | // A single unbroken run has no boundary to prefer; it is cut anyway | |
| 473 | + | // rather than allowed through at full length. | |
| 474 | + | let wall = "x".repeat(200); | |
| 475 | + | assert_eq!(summarize(&wall).unwrap().chars().count(), PROMPT_MAX + 1); | |
| 476 | + | } | |
| 477 | + | ||
| 359 | 478 | #[test] | |
| 360 | 479 | fn session_ids_cannot_escape_the_directory() { | |
| 361 | 480 | assert_eq!(sanitize_id("../../etc/passwd"), "etcpasswd"); | |
| ⋯ 3 unchanged lines | |||
modifieddaemon/src/lib.rs+1 −0
| ⋯ 2 unchanged lines | |||
| 3 | 3 | pub mod auth; | |
| 4 | 4 | pub mod control; | |
| 5 | 5 | pub mod install; | |
| 6 | + | pub mod pane_title; | |
| 6 | 7 | pub mod paths; | |
| 7 | 8 | pub mod project; | |
| 8 | 9 | pub mod pty; | |
| ⋯ 12 unchanged lines | |||
modifieddaemon/src/main.rs+11 −1
| ⋯ 17 unchanged lines | |||
| 18 | 18 | termbridge install [--port N] [--idle-timeout SECS] [--session NAME] | |
| 19 | 19 | start the daemon on demand, from now on | |
| 20 | 20 | termbridge uninstall stop doing that | |
| 21 | - | termbridge reload restart the running daemon (after a rebuild) | |
| 21 | + | termbridge reload restart the running daemon, re-source tmux.conf | |
| 22 | 22 | termbridge token print the auth token (paste into extension) | |
| 23 | 23 | termbridge token --rotate generate a new token | |
| 24 | 24 | termbridge pair <origin> approve an extension origin | |
| ⋯ 74 unchanged lines | |||
| 99 | 99 | // install under another name. | |
| 100 | 100 | Some("reload") => { | |
| 101 | 101 | termbridge::install::reload()?; | |
| 102 | + | // The other half of an edit loop. The formats in ~/.tmux.conf name | |
| 103 | + | // options this daemon's hooks set, so the two are changed together | |
| 104 | + | // often enough that reloading one and not the other is the state | |
| 105 | + | // you end up debugging. | |
| 106 | + | for file in termbridge::window_status::reload_conf() { | |
| 107 | + | println!("sourced {file}"); | |
| 108 | + | } | |
| 102 | 109 | Ok(()) | |
| 103 | 110 | } | |
| 104 | 111 | Some("token") => { | |
| ⋯ 55 unchanged lines | |||
| 160 | 167 | // the event removed the record: that is what clears the glyph | |
| 161 | 168 | // from a window whose Claude has gone. | |
| 162 | 169 | termbridge::window_status::publish(); | |
| 170 | + | // Same order and the same reason: the title is read back out of | |
| 171 | + | // the record that was just written. | |
| 172 | + | termbridge::pane_title::publish(); | |
| 163 | 173 | } | |
| 164 | 174 | Ok(()) | |
| 165 | 175 | } | |
| ⋯ 163 unchanged lines | |||
addeddaemon/src/pane_title.rs+88 −0
| 1 | + | //! What a pane's Claude is called, published as a tmux pane option. | |
| 2 | + | //! | |
| 3 | + | //! A pane running Claude is otherwise indistinguishable from any other pane: | |
| 4 | + | //! `#{pane_current_command}` is `node` in all of them and the cwd is the same | |
| 5 | + | //! repo in half of them. What tells two of them apart is the session's name, or | |
| 6 | + | //! before Claude has thought of one, the last thing you asked it — and the hook | |
| 7 | + | //! is handed both, `session_name` and `prompt`, as fields. | |
| 8 | + | //! | |
| 9 | + | //! They go into a pane option rather than the pane title. The title is a shared | |
| 10 | + | //! channel: Claude Code writes its own name there over OSC, a shell writes the | |
| 11 | + | //! cwd there, and a status line reading it back has to guess which of them it | |
| 12 | + | //! got and what shape it is in. An `@`-prefixed option is ours alone, so | |
| 13 | + | //! `#{@tb_title}` is either the name or empty, and no format has to take the | |
| 14 | + | //! string apart to find out. | |
| 15 | + | //! | |
| 16 | + | //! Pane-scoped, because tmux resolves a pane option against the active pane of | |
| 17 | + | //! the window being drawn: `#{@tb_title}` in `window-status-format` names the | |
| 18 | + | //! window after whichever pane you are looking at, with nothing here having to | |
| 19 | + | //! know which one that is — or to touch the window at all. | |
| 20 | + | //! | |
| 21 | + | //! Like [`crate::window_status`], this runs inside a Claude Code hook and | |
| 22 | + | //! swallows every error: a stale name is not worth interrupting a session over. | |
| 23 | + | ||
| 24 | + | use std::process::{Command, Stdio}; | |
| 25 | + | ||
| 26 | + | use crate::agents; | |
| 27 | + | ||
| 28 | + | /// The option the format reads. `@`-prefixed, so tmux treats it as a user | |
| 29 | + | /// option and no future tmux release can collide with it. | |
| 30 | + | pub const OPTION: &str = "@tb_title"; | |
| 31 | + | ||
| 32 | + | /// Set the option on the pane the hook fired in, from that pane's record. | |
| 33 | + | /// | |
| 34 | + | /// Unset when there is nothing to say — including when the record has just been | |
| 35 | + | /// removed by `SessionEnd`, which is what takes the name off a pane that is | |
| 36 | + | /// back to being an ordinary shell. That is safe here in a way clearing a pane | |
| 37 | + | /// *title* would not be: the option has no other writer. | |
| 38 | + | pub fn publish() { | |
| 39 | + | if std::env::var_os("TMUX").is_none() { | |
| 40 | + | return; | |
| 41 | + | } | |
| 42 | + | let Ok(pane) = std::env::var("TMUX_PANE") else { | |
| 43 | + | return; | |
| 44 | + | }; | |
| 45 | + | // Claude's own name for the session first — it is a summary, and the prompt | |
| 46 | + | // is only ever the raw material for one. Before it exists, and it does not | |
| 47 | + | // exist for most of a session's life, what you asked beats nothing. | |
| 48 | + | let title = agents::by_pane() | |
| 49 | + | .get(&pane) | |
| 50 | + | .and_then(|r| r.name.clone().or_else(|| r.prompt.clone())); | |
| 51 | + | ||
| 52 | + | match title { | |
| 53 | + | Some(t) => tmux(&["set-option", "-p", "-t", &pane, OPTION, &escape(&t)]), | |
| 54 | + | None => tmux(&["set-option", "-pu", "-t", &pane, OPTION]), | |
| 55 | + | }; | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | /// Text on its way into a place tmux will expand as a format — an option's | |
| 59 | + | /// value is expanded every time the status line draws, so a prompt containing | |
| 60 | + | /// `#{...}` or `#[fg=red]` would be interpolated or restyled. Doubling `#` is | |
| 61 | + | /// tmux's own escape and the only one needed: every format construct starts | |
| 62 | + | /// with one. | |
| 63 | + | pub(crate) fn escape(title: &str) -> String { | |
| 64 | + | title.replace('#', "##") | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | fn tmux(args: &[&str]) -> String { | |
| 68 | + | Command::new("tmux") | |
| 69 | + | .args(args) | |
| 70 | + | .stdin(Stdio::null()) | |
| 71 | + | .stderr(Stdio::null()) | |
| 72 | + | .output() | |
| 73 | + | .ok() | |
| 74 | + | .and_then(|o| String::from_utf8(o.stdout).ok()) | |
| 75 | + | .unwrap_or_default() | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | #[cfg(test)] | |
| 79 | + | mod tests { | |
| 80 | + | use super::*; | |
| 81 | + | ||
| 82 | + | #[test] | |
| 83 | + | fn format_constructs_in_a_prompt_are_inert() { | |
| 84 | + | assert_eq!(escape("count #{host} items"), "count ##{host} items"); | |
| 85 | + | assert_eq!(escape("#[fg=red]"), "##[fg=red]"); | |
| 86 | + | assert_eq!(escape("plain"), "plain"); | |
| 87 | + | } | |
| 88 | + | } |
modifieddaemon/src/status.rs+5 −2
| ⋯ 58 unchanged lines | |||
| 59 | 59 | pub tool: Option<String>, | |
| 60 | 60 | /// Why it is waiting on you. | |
| 61 | 61 | pub message: Option<String>, | |
| 62 | - | /// Claude's session title, when it has one. | |
| 62 | + | /// Claude's session title, or — until it has one — the last thing the user | |
| 63 | + | /// asked it. The same string [`crate::pane_title`] gives to tmux, so a | |
| 64 | + | /// session reads the same in the sidebar and in the status line. | |
| 63 | 65 | pub title: Option<String>, | |
| 64 | 66 | } | |
| 65 | 67 | ||
| ⋯ 367 unchanged lines | |||
| 433 | 435 | mode: record.and_then(|r| r.mode.clone()), | |
| 434 | 436 | tool: record.and_then(|r| r.tool.clone()), | |
| 435 | 437 | message: record.and_then(|r| r.message.clone()), | |
| 436 | - | title: record.and_then(|r| r.name.clone()), | |
| 438 | + | title: record.and_then(|r| r.name.clone().or_else(|| r.prompt.clone())), | |
| 437 | 439 | }) | |
| 438 | 440 | }) | |
| 439 | 441 | .collect(); | |
| ⋯ 21 unchanged lines | |||
| 461 | 463 | tool: None, | |
| 462 | 464 | message: None, | |
| 463 | 465 | name: None, | |
| 466 | + | prompt: None, | |
| 464 | 467 | frame: 0, | |
| 465 | 468 | updated, | |
| 466 | 469 | } | |
| ⋯ 48 unchanged lines | |||
modifieddaemon/src/window_status.rs+40 −4
| ⋯ 97 unchanged lines | |||
| 98 | 98 | let _ = tmux(&["set-option", "-wu", "-t", pane, OPTION]); | |
| 99 | 99 | } | |
| 100 | 100 | ||
| 101 | + | /// Re-source the running tmux server's own configuration, and report what was | |
| 102 | + | /// sourced. | |
| 103 | + | /// | |
| 104 | + | /// tmux has no "reload config" command, so the files have to be named — and | |
| 105 | + | /// `#{config_files}` is tmux naming them itself, which is the only answer that | |
| 106 | + | /// stays right for a user whose config lives somewhere this code has never | |
| 107 | + | /// heard of. It is a list of candidates rather than of files that exist, hence | |
| 108 | + | /// `-q`: `~/.tmux.conf` is on it for everyone, including everyone who keeps | |
| 109 | + | /// their config in `~/.config/tmux` instead. | |
| 110 | + | /// | |
| 111 | + | /// Empty when no tmux server is running, which is not a failure — there is | |
| 112 | + | /// simply nothing holding stale settings. | |
| 113 | + | pub fn reload_conf() -> Vec<String> { | |
| 114 | + | let files: Vec<String> = tmux(&["display-message", "-p", "#{config_files}"]) | |
| 115 | + | .trim() | |
| 116 | + | .split(',') | |
| 117 | + | .filter(|f| !f.is_empty()) | |
| 118 | + | .map(str::to_string) | |
| 119 | + | .collect(); | |
| 120 | + | for file in &files { | |
| 121 | + | let _ = tmux(&["source-file", "-q", file]); | |
| 122 | + | } | |
| 123 | + | files | |
| 124 | + | } | |
| 125 | + | ||
| 101 | 126 | /// No `-L`/`-S`: the hook runs inside the pane, so `$TMUX` already names the | |
| 102 | 127 | /// server the user is looking at, and inheriting it is what makes this correct | |
| 103 | 128 | /// under multiple tmux servers. | |
| ⋯ 11 unchanged lines | |||
| 115 | 140 | /// What `termbridge tmux` prints. Two formats, because tmux styles the current | |
| 116 | 141 | /// window with a separate one and a glyph that vanished on the window you were | |
| 117 | 142 | /// on would be the wrong half to lose. | |
| 143 | + | /// | |
| 144 | + | /// The name in place of `#W` is [`crate::pane_title`]: a pane option, so tmux | |
| 145 | + | /// resolves it against the active pane of the window it is drawing, and the | |
| 146 | + | /// window reads as whichever pane you are looking at. It is empty rather than | |
| 147 | + | /// oddly shaped when there is no Claude, so `#W` is the whole fallback — and | |
| 148 | + | /// `#{=/24/…:}` because a name is a sentence where a window name was a word, | |
| 149 | + | /// and four of them would otherwise push the clock off the status line. | |
| 118 | 150 | pub fn tmux_conf() -> String { | |
| 151 | + | let title = crate::pane_title::OPTION; | |
| 152 | + | let name = format!("#{{?#{{{title}}},#{{=/24/…:#{{{title}}}}},#W}}"); | |
| 119 | 153 | format!( | |
| 120 | 154 | "# Claude Code status in the tmux status line, from termbridge's hooks.\n\ | |
| 121 | - | # The option is empty for a window with no Claude in it, so windows\n\ | |
| 122 | - | # that never see one look exactly as they do now.\n\ | |
| 123 | - | set -g window-status-format \"#{{{OPTION}}}#I:#W#F\"\n\ | |
| 124 | - | set -g window-status-current-format \"#{{{OPTION}}}#I:#W#F\"\n" | |
| 155 | + | # Both options are empty for a pane with no Claude in it, so windows\n\ | |
| 156 | + | # that never see one look exactly as they do now: no glyph, and the\n\ | |
| 157 | + | # window name tmux would have shown anyway.\n\ | |
| 158 | + | set -g window-status-format \"#{{{OPTION}}}#I:{name}#F\"\n\ | |
| 159 | + | set -g window-status-current-format \"#{{{OPTION}}}#I:{name}#F\"\n" | |
| 125 | 160 | ) | |
| 126 | 161 | } | |
| 127 | 162 | ||
| ⋯ 11 unchanged lines | |||
| 139 | 174 | tool: None, | |
| 140 | 175 | message: None, | |
| 141 | 176 | name: None, | |
| 177 | + | prompt: None, | |
| 142 | 178 | frame, | |
| 143 | 179 | updated: 0, | |
| 144 | 180 | } | |
| ⋯ 23 unchanged lines | |||
addedextension/lib/devport.js+112 −0
| 1 | + | // The `devport` convention, reimplemented so the panel can apply it offline. | |
| 2 | + | // | |
| 3 | + | // devport(1) is a shell script that hands a local project a stable block of ten | |
| 4 | + | // ports: `BASE + (cksum(name) % BLOCKS) * SLOT`, where the name is the git | |
| 5 | + | // repo's directory name. Nothing registers anything — the hash *is* the | |
| 6 | + | // registry — which is what makes it reproducible here. | |
| 7 | + | // | |
| 8 | + | // The panel wants the question the other way round: a browser tab is sitting on | |
| 9 | + | // http://localhost:12345, and it wants to know which tmux session that port | |
| 10 | + | // belongs to. devport answers that with `-r`, by walking ~/Code and hashing | |
| 11 | + | // every directory in it. We do not need the walk: the daemon already tells us | |
| 12 | + | // each session's working directory, so hashing those few names forward and | |
| 13 | + | // comparing blocks gives the same answer without a subprocess, a filesystem, or | |
| 14 | + | // devport being installed at all. | |
| 15 | + | // | |
| 16 | + | // The one thing that must not drift is the hash. `portof()` in devport is | |
| 17 | + | // `printf '%s' "$name" | cksum`, and POSIX cksum is CRC-32/CKSUM: the ordinary | |
| 18 | + | // CRC-32 polynomial, unreflected, with the message length appended to the | |
| 19 | + | // message. Any faster-looking CRC-32 (zlib's, reflected, no length) computes a | |
| 20 | + | // different number and would point at the wrong project. | |
| 21 | + | ||
| 22 | + | /** First assignable port. Below this is the crowd of framework defaults. */ | |
| 23 | + | const DEVPORT_BASE = 10240; | |
| 24 | + | /** How many blocks exist: 2100 * 10 covers 10240-31239. */ | |
| 25 | + | const DEVPORT_BLOCKS = 2100; | |
| 26 | + | /** Ports per project — app, db, cache, debugger, whatever. */ | |
| 27 | + | const DEVPORT_SLOT = 10; | |
| 28 | + | ||
| 29 | + | /** Lazily built, because this runs on every tab activation. @type {number[] | null} */ | |
| 30 | + | let table = null; | |
| 31 | + | ||
| 32 | + | /** The CRC-32/CKSUM byte table, poly 0x04C11DB7, unreflected. */ | |
| 33 | + | function crcTable() { | |
| 34 | + | if (table) return table; | |
| 35 | + | const t = new Array(256); | |
| 36 | + | for (let i = 0; i < 256; i++) { | |
| 37 | + | let c = i << 24; | |
| 38 | + | for (let k = 0; k < 8; k++) c = c & 0x80000000 ? (c << 1) ^ 0x04c11db7 : c << 1; | |
| 39 | + | t[i] = c >>> 0; | |
| 40 | + | } | |
| 41 | + | table = t; | |
| 42 | + | return t; | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | /** | |
| 46 | + | * POSIX `cksum`, as a number. | |
| 47 | + | * | |
| 48 | + | * The trailing length loop is not decoration: cksum feeds the byte count in | |
| 49 | + | * after the data, low byte first, dropping the high zero bytes. Without it the | |
| 50 | + | * checksums differ from the shell's for every input. | |
| 51 | + | * | |
| 52 | + | * @param {string} s | |
| 53 | + | * @returns {number} | |
| 54 | + | */ | |
| 55 | + | function cksum(s) { | |
| 56 | + | const t = crcTable(); | |
| 57 | + | const bytes = new TextEncoder().encode(s); | |
| 58 | + | let crc = 0; | |
| 59 | + | for (const b of bytes) crc = ((crc << 8) ^ t[((crc >>> 24) ^ b) & 0xff]) >>> 0; | |
| 60 | + | for (let n = bytes.length; n !== 0; n = Math.floor(n / 256)) { | |
| 61 | + | crc = ((crc << 8) ^ t[((crc >>> 24) ^ (n & 0xff)) & 0xff]) >>> 0; | |
| 62 | + | } | |
| 63 | + | return (crc ^ 0xffffffff) >>> 0; | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | /** | |
| 67 | + | * The first port of the block a project name owns — what plain `devport` prints | |
| 68 | + | * in a directory of that name. | |
| 69 | + | * | |
| 70 | + | * @param {string} name | |
| 71 | + | * @returns {number} | |
| 72 | + | */ | |
| 73 | + | function portOf(name) { | |
| 74 | + | return DEVPORT_BASE + (cksum(name) % DEVPORT_BLOCKS) * DEVPORT_SLOT; | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | /** | |
| 78 | + | * The block a port falls in, or null when it is outside the assignable range — | |
| 79 | + | * 3000, 5173, 8080 and every other hand-picked port land here, and they carry | |
| 80 | + | * no project in them to find. | |
| 81 | + | * | |
| 82 | + | * @param {number} port | |
| 83 | + | * @returns {number | null} | |
| 84 | + | */ | |
| 85 | + | function blockOf(port) { | |
| 86 | + | if (!Number.isInteger(port)) return null; | |
| 87 | + | if (port < DEVPORT_BASE || port >= DEVPORT_BASE + DEVPORT_BLOCKS * DEVPORT_SLOT) return null; | |
| 88 | + | return port - ((port - DEVPORT_BASE) % DEVPORT_SLOT); | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | /** | |
| 92 | + | * Whether `port` is one of the ten this name owns. | |
| 93 | + | * | |
| 94 | + | * A block is ten ports wide and there are only 2100 blocks, so a match is | |
| 95 | + | * evidence and not proof — two projects in ~/Code can share one. That is why | |
| 96 | + | * this only ever *suggests* a pin rather than making one. | |
| 97 | + | * | |
| 98 | + | * @param {number} port | |
| 99 | + | * @param {string} name | |
| 100 | + | * @returns {boolean} | |
| 101 | + | */ | |
| 102 | + | function owns(port, name) { | |
| 103 | + | if (!name) return false; | |
| 104 | + | const block = blockOf(port); | |
| 105 | + | return block !== null && block === portOf(name); | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | const Devport = { BASE: DEVPORT_BASE, BLOCKS: DEVPORT_BLOCKS, SLOT: DEVPORT_SLOT, cksum, portOf, blockOf, owns }; | |
| 109 | + | ||
| 110 | + | if (typeof module !== "undefined" && module.exports) { | |
| 111 | + | module.exports = Devport; | |
| 112 | + | } |
addedextension/lib/devport.test.js+71 −0
| 1 | + | // Run with: node --test "extension/lib/*.test.js" | |
| 2 | + | // | |
| 3 | + | // The whole value of this module is agreeing with the devport shell script, so | |
| 4 | + | // most of what is checked here are ports taken from running it. If cksum is | |
| 5 | + | // ever "optimised" into an ordinary zlib CRC-32 these are the tests that fail. | |
| 6 | + | ||
| 7 | + | const test = require("node:test"); | |
| 8 | + | const assert = require("node:assert"); | |
| 9 | + | ||
| 10 | + | const Devport = require("./devport.js"); | |
| 11 | + | ||
| 12 | + | // `devport -n <name>`, run for real. | |
| 13 | + | const KNOWN = { | |
| 14 | + | "browser-terminal-extension": 26210, | |
| 15 | + | myapp: 26270, | |
| 16 | + | dotfiles: 26140, | |
| 17 | + | termbridge: 12820, | |
| 18 | + | Code: 25000, | |
| 19 | + | a: 30900, | |
| 20 | + | }; | |
| 21 | + | ||
| 22 | + | test("agrees with the devport script", () => { | |
| 23 | + | for (const [name, port] of Object.entries(KNOWN)) { | |
| 24 | + | assert.equal(Devport.portOf(name), port, name); | |
| 25 | + | } | |
| 26 | + | }); | |
| 27 | + | ||
| 28 | + | test("cksum matches the POSIX checksum", () => { | |
| 29 | + | // `printf '%s' "" | cksum` and `printf '%s' abc | cksum`. | |
| 30 | + | assert.equal(Devport.cksum(""), 4294967295); | |
| 31 | + | assert.equal(Devport.cksum("abc"), 1219131554); | |
| 32 | + | }); | |
| 33 | + | ||
| 34 | + | test("every port lands in the assignable range, on a block boundary", () => { | |
| 35 | + | const top = Devport.BASE + Devport.BLOCKS * Devport.SLOT; | |
| 36 | + | for (let i = 0; i < 500; i++) { | |
| 37 | + | const port = Devport.portOf(`project-${i}`); | |
| 38 | + | assert.ok(port >= Devport.BASE && port < top, `${port} out of range`); | |
| 39 | + | assert.equal((port - Devport.BASE) % Devport.SLOT, 0); | |
| 40 | + | } | |
| 41 | + | }); | |
| 42 | + | ||
| 43 | + | test("a block covers the ten ports that follow its base", () => { | |
| 44 | + | const base = Devport.portOf("myapp"); | |
| 45 | + | for (let i = 0; i < Devport.SLOT; i++) { | |
| 46 | + | assert.equal(Devport.blockOf(base + i), base, `offset ${i}`); | |
| 47 | + | assert.ok(Devport.owns(base + i, "myapp")); | |
| 48 | + | } | |
| 49 | + | // One past the end belongs to the next block, not this one. | |
| 50 | + | assert.notEqual(Devport.blockOf(base + Devport.SLOT), base); | |
| 51 | + | }); | |
| 52 | + | ||
| 53 | + | test("ports outside the range belong to nobody", () => { | |
| 54 | + | for (const port of [80, 3000, 5173, 8080, 10239, 31240, 65535, -1, 1.5, NaN]) { | |
| 55 | + | assert.equal(Devport.blockOf(port), null, String(port)); | |
| 56 | + | assert.equal(Devport.owns(port, "myapp"), false, String(port)); | |
| 57 | + | } | |
| 58 | + | }); | |
| 59 | + | ||
| 60 | + | test("an empty name owns nothing", () => { | |
| 61 | + | // Not because it hashes badly — it hashes fine — but because a session with | |
| 62 | + | // no working directory would otherwise match one arbitrary block. | |
| 63 | + | assert.equal(Devport.owns(Devport.portOf(""), ""), false); | |
| 64 | + | }); | |
| 65 | + | ||
| 66 | + | test("unicode names hash by their bytes", () => { | |
| 67 | + | // The shell pipes bytes into cksum, so the name has to be UTF-8 encoded here | |
| 68 | + | // rather than iterated as UTF-16 code units. | |
| 69 | + | assert.equal(Devport.portOf("café"), Devport.portOf("café")); | |
| 70 | + | assert.notEqual(Devport.portOf("café"), Devport.portOf("cafe")); | |
| 71 | + | }); |
addedextension/lib/tabpin.js+437 −0
| 1 | + | // Pinning a tmux session (or one window of one) to a browser tab. | |
| 2 | + | // | |
| 3 | + | // The point is that moving between browser tabs moves the terminal with you: | |
| 4 | + | // the tab you keep the app in brings up the session you run the app from, and | |
| 5 | + | // the docs tab beside it brings up whatever you had there. | |
| 6 | + | // | |
| 7 | + | // The same pin also reads backwards — moving the terminal brings the browser | |
| 8 | + | // along — and that half is the more delicate one, because the browser is where | |
| 9 | + | // the user's hands are. Three things keep it from yanking anything: | |
| 10 | + | // | |
| 11 | + | // - It only ever *activates* a tab that is already open in the panel's own | |
| 12 | + | // browser window. Nothing is created, nothing is focused, no window is | |
| 13 | + | // raised. The worst it can do is show you a tab you already had. | |
| 14 | + | // - It does nothing at all when the tab already showing satisfies the pin, | |
| 15 | + | // which is what stops the two directions from chasing each other: forward | |
| 16 | + | // will not move a terminal that is already where the tab points, and | |
| 17 | + | // backward will not move a browser that is already on a tab that points | |
| 18 | + | // here. Whichever fires second finds its work done. | |
| 19 | + | // - It is off in one checkbox, separately from the forward direction, for | |
| 20 | + | // anyone who wants the terminal to follow without the browser leading. | |
| 21 | + | // | |
| 22 | + | // Two kinds of pin, and the difference is what they key on: | |
| 23 | + | // | |
| 24 | + | // by origin http://localhost:26210 -> a session. Any tab on that origin | |
| 25 | + | // matches, and the pin outlives the tab, the window and the | |
| 26 | + | // browser itself, because an origin is a name and a tab id is a | |
| 27 | + | // handle. This is the one to reach for. | |
| 28 | + | // by tab this exact tab, by the id Chrome gave it. Survives nothing — | |
| 29 | + | // tab ids are not reissued but they are not remembered across a | |
| 30 | + | // restart either — and exists for the case an origin cannot | |
| 31 | + | // express: two tabs on the same site pointing at different | |
| 32 | + | // sessions, or a page whose URL says nothing (a file:, a blank | |
| 33 | + | // tab, one of five identical Jira boards). | |
| 34 | + | // | |
| 35 | + | // A tab pin wins over an origin pin, being the more specific statement. Below | |
| 36 | + | // both sits detection: a tab on a localhost port that devport would hand to a | |
| 37 | + | // project we have a session in is treated as pinned to that session even though | |
| 38 | + | // nobody said so. That guess is only ever a fallback — an explicit pin at | |
| 39 | + | // either level overrides it, including the explicit *veto* that unpinning an | |
| 40 | + | // auto-matched tab writes, which is the only way to say "no, not this one" to a | |
| 41 | + | // rule that would otherwise keep re-deriving itself. | |
| 42 | + | // | |
| 43 | + | // Everything here is pure. Reading tabs, writing storage and telling tmux to | |
| 44 | + | // move are all sidebar.js's; this module only answers "given what is stored and | |
| 45 | + | // what is on the server, where should the terminal be?" | |
| 46 | + | ||
| 47 | + | /** Storage key. Deliberately not `pins`, which is this panel's *window* pins. */ | |
| 48 | + | const TABPIN_KEY = "tabPins"; | |
| 49 | + | ||
| 50 | + | /** | |
| 51 | + | * An empty store. Shaped rather than `{}` so every caller can index into it | |
| 52 | + | * without a guard. | |
| 53 | + | * @returns {TbTabPinStore} | |
| 54 | + | */ | |
| 55 | + | function emptyStore() { | |
| 56 | + | return { enabled: true, detect: true, reverse: true, byTab: {}, byOrigin: {} }; | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | /** | |
| 60 | + | * Storage is not a promise: it holds whatever some earlier version of this | |
| 61 | + | * panel wrote, and a hostile page cannot reach it but a bug can. So anything | |
| 62 | + | * that is not the shape we expect is dropped rather than trusted. | |
| 63 | + | * | |
| 64 | + | * @param {unknown} raw | |
| 65 | + | * @returns {TbTabPinStore} | |
| 66 | + | */ | |
| 67 | + | function loadStore(raw) { | |
| 68 | + | const store = emptyStore(); | |
| 69 | + | if (!raw || typeof raw !== "object") return store; | |
| 70 | + | const v = /** @type {Record<string, unknown>} */ (raw); | |
| 71 | + | if (v.enabled === false) store.enabled = false; | |
| 72 | + | if (v.detect === false) store.detect = false; | |
| 73 | + | if (v.reverse === false) store.reverse = false; | |
| 74 | + | store.byTab = cleanMap(v.byTab); | |
| 75 | + | store.byOrigin = cleanMap(v.byOrigin); | |
| 76 | + | return store; | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | /** @param {unknown} raw @returns {Record<string, TbPinTarget | null>} */ | |
| 80 | + | function cleanMap(raw) { | |
| 81 | + | /** @type {Record<string, TbPinTarget | null>} */ | |
| 82 | + | const out = {}; | |
| 83 | + | if (!raw || typeof raw !== "object") return out; | |
| 84 | + | for (const [key, value] of Object.entries(raw)) { | |
| 85 | + | // null is a veto — "this tab is pinned to nothing, stop guessing" — and is | |
| 86 | + | // as meaningful a stored value as a target. | |
| 87 | + | if (value === null) { | |
| 88 | + | out[key] = null; | |
| 89 | + | continue; | |
| 90 | + | } | |
| 91 | + | if (!value || typeof value !== "object") continue; | |
| 92 | + | const t = /** @type {Record<string, unknown>} */ (value); | |
| 93 | + | if (typeof t.session !== "string" || !t.session) continue; | |
| 94 | + | out[key] = { | |
| 95 | + | session: t.session, | |
| 96 | + | window: typeof t.window === "string" && t.window ? t.window : null, | |
| 97 | + | }; | |
| 98 | + | } | |
| 99 | + | return out; | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | /** | |
| 103 | + | * The origin a pin would key on, or "" for a tab that cannot carry one. | |
| 104 | + | * | |
| 105 | + | * Only http and https. Everything else — the browser's own pages, extension | |
| 106 | + | * pages, `file:`, `about:blank` — either has no origin worth the name or is a | |
| 107 | + | * page this extension is not allowed to look at anyway, and a tab pin is the | |
| 108 | + | * answer for those. | |
| 109 | + | * | |
| 110 | + | * @param {string | undefined} url | |
| 111 | + | * @returns {string} | |
| 112 | + | */ | |
| 113 | + | function originOf(url) { | |
| 114 | + | if (!url) return ""; | |
| 115 | + | try { | |
| 116 | + | const u = new URL(url); | |
| 117 | + | if (u.protocol !== "http:" && u.protocol !== "https:") return ""; | |
| 118 | + | return u.origin; | |
| 119 | + | } catch { | |
| 120 | + | return ""; | |
| 121 | + | } | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | /** | |
| 125 | + | * The port a local development server would be on, or null. | |
| 126 | + | * | |
| 127 | + | * Local only, and deliberately so: devport's mapping says nothing about a port | |
| 128 | + | * on someone else's host, and a public site that happens to sit on :26210 is | |
| 129 | + | * not your project. | |
| 130 | + | * | |
| 131 | + | * @param {string} origin | |
| 132 | + | * @returns {number | null} | |
| 133 | + | */ | |
| 134 | + | function localPort(origin) { | |
| 135 | + | if (!origin) return null; | |
| 136 | + | try { | |
| 137 | + | const u = new URL(origin); | |
| 138 | + | const host = u.hostname; | |
| 139 | + | const local = | |
| 140 | + | host === "localhost" || | |
| 141 | + | host === "127.0.0.1" || | |
| 142 | + | host === "[::1]" || | |
| 143 | + | host === "::1" || | |
| 144 | + | host.endsWith(".localhost"); | |
| 145 | + | if (!local || !u.port) return null; | |
| 146 | + | return Number(u.port); | |
| 147 | + | } catch { | |
| 148 | + | return null; | |
| 149 | + | } | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | /** | |
| 153 | + | * The last path segment, which is the name devport hashes: it takes the git | |
| 154 | + | * repo's directory name, and a session's working directory is normally that | |
| 155 | + | * directory or something under it. | |
| 156 | + | * | |
| 157 | + | * @param {string | null | undefined} path | |
| 158 | + | * @returns {string} | |
| 159 | + | */ | |
| 160 | + | function baseName(path) { | |
| 161 | + | if (!path) return ""; | |
| 162 | + | const parts = path.replace(/\/+$/, "").split("/"); | |
| 163 | + | return parts[parts.length - 1] ?? ""; | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | /** | |
| 167 | + | * The session a localhost origin most likely belongs to, by devport's hash, or | |
| 168 | + | * null when nothing matches. | |
| 169 | + | * | |
| 170 | + | * Two candidate names per session, because either can be the one devport was | |
| 171 | + | * run under: the working directory's own name, and the session name (the | |
| 172 | + | * omnibar names sessions after the project directory, so it usually *is* the | |
| 173 | + | * name). The directory is checked first — it is what devport actually reads. | |
| 174 | + | * | |
| 175 | + | * Ambiguity resolves to nothing. Blocks collide by design, and a wrong | |
| 176 | + | * auto-switch is worse than no auto-switch: it moves a terminal you were | |
| 177 | + | * reading out from under you for a reason you cannot see. | |
| 178 | + | * | |
| 179 | + | * @param {string} origin | |
| 180 | + | * @param {TbSessionInfo[]} sessions | |
| 181 | + | * @param {typeof Devport} devport | |
| 182 | + | * @returns {TbPinTarget | null} | |
| 183 | + | */ | |
| 184 | + | function detectTarget(origin, sessions, devport) { | |
| 185 | + | const port = localPort(origin); | |
| 186 | + | if (port === null || devport.blockOf(port) === null) return null; | |
| 187 | + | ||
| 188 | + | /** @type {TbSessionInfo[]} */ | |
| 189 | + | const hits = []; | |
| 190 | + | for (const s of sessions) { | |
| 191 | + | if (devport.owns(port, baseName(s.path)) || devport.owns(port, s.name)) hits.push(s); | |
| 192 | + | } | |
| 193 | + | if (hits.length !== 1) return null; | |
| 194 | + | return { session: hits[0].name, window: null }; | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | /** | |
| 198 | + | * Where the terminal should go for this tab, and why — or null for "stay where | |
| 199 | + | * you are", which is the answer for every tab nobody has said anything about. | |
| 200 | + | * | |
| 201 | + | * @param {TbTabPinStore} store | |
| 202 | + | * @param {{ id?: number, url?: string }} tab | |
| 203 | + | * @param {TbSessionInfo[]} sessions | |
| 204 | + | * @param {typeof Devport} devport | |
| 205 | + | * @returns {{ target: TbPinTarget, source: TbPinSource } | null} | |
| 206 | + | */ | |
| 207 | + | function resolve(store, tab, sessions, devport) { | |
| 208 | + | if (!store.enabled) return null; | |
| 209 | + | const origin = originOf(tab.url); | |
| 210 | + | ||
| 211 | + | // Presence is the statement, so `in` rather than a truthiness test: a stored | |
| 212 | + | // null is a veto and has to stop the search rather than fall through it. | |
| 213 | + | const byTab = tab.id != null ? String(tab.id) : ""; | |
| 214 | + | if (byTab && byTab in store.byTab) { | |
| 215 | + | const t = store.byTab[byTab]; | |
| 216 | + | return t ? { target: t, source: "tab" } : null; | |
| 217 | + | } | |
| 218 | + | if (origin && origin in store.byOrigin) { | |
| 219 | + | const t = store.byOrigin[origin]; | |
| 220 | + | return t ? { target: t, source: "origin" } : null; | |
| 221 | + | } | |
| 222 | + | if (!store.detect || !origin) return null; | |
| 223 | + | const auto = detectTarget(origin, sessions, devport); | |
| 224 | + | return auto ? { target: auto, source: "detect" } : null; | |
| 225 | + | } | |
| 226 | + | ||
| 227 | + | /** | |
| 228 | + | * How specific a rule is, for picking between two tabs that both point here. | |
| 229 | + | * The same order the forward direction searches in, which is the point: the | |
| 230 | + | * tab a pin would choose going one way is the tab it chooses coming back. | |
| 231 | + | */ | |
| 232 | + | const SOURCE_RANK = { tab: 0, origin: 1, detect: 2 }; | |
| 233 | + | ||
| 234 | + | /** | |
| 235 | + | * The browser tab to show now that the terminal is at `spot` — or null for | |
| 236 | + | * "leave the browser alone", which is the answer far more often than not. | |
| 237 | + | * | |
| 238 | + | * Deliberately expressed as the forward rule run over every tab rather than as | |
| 239 | + | * a second set of rules read out of the store backwards. A pin, a site pin, a | |
| 240 | + | * veto and a devport guess already compose into one answer per tab; asking each | |
| 241 | + | * tab "would you bring the terminal here?" and keeping the ones that say yes | |
| 242 | + | * means the two directions cannot disagree about what a pin means, and that a | |
| 243 | + | * veto keeps vetoing when read from this end. | |
| 244 | + | * | |
| 245 | + | * Two tabs can answer yes — two tabs on the pinned origin, most obviously — so | |
| 246 | + | * ties go to the more specific rule, and then to whichever tab is earlier in | |
| 247 | + | * `tabs`. The caller sorts by last use, so that is the one you were reading. | |
| 248 | + | * | |
| 249 | + | * The tab already showing wins outright, by stopping the search: if it points | |
| 250 | + | * here there is nothing to do, and doing nothing is what keeps this from | |
| 251 | + | * fighting the forward direction. | |
| 252 | + | * | |
| 253 | + | * @param {TbTabPinStore} store | |
| 254 | + | * @param {TbSpot} spot where the terminal is now | |
| 255 | + | * @param {{ id?: number, url?: string, active?: boolean }[]} tabs the panel's | |
| 256 | + | * own browser window, most recently used first | |
| 257 | + | * @param {TbSessionInfo[]} sessions | |
| 258 | + | * @param {typeof Devport} devport | |
| 259 | + | * @returns {{ tabId: number, source: TbPinSource } | null} | |
| 260 | + | */ | |
| 261 | + | function reverse(store, spot, tabs, sessions, devport) { | |
| 262 | + | if (!store.enabled || !store.reverse) return null; | |
| 263 | + | /** @type {{ tabId: number, source: TbPinSource } | null} */ | |
| 264 | + | let best = null; | |
| 265 | + | for (const tab of tabs) { | |
| 266 | + | if (tab.id == null) continue; | |
| 267 | + | const hit = resolve(store, tab, sessions, devport); | |
| 268 | + | if (!hit) continue; | |
| 269 | + | // Against the *resolved* pin, not the pin as written, for the reason | |
| 270 | + | // `applyTabPin` compares that way too: a pin to a window that has closed is | |
| 271 | + | // a pin to its session, and has to compare as one from either end. | |
| 272 | + | const at = locate(hit.target, sessions); | |
| 273 | + | if (!at) continue; | |
| 274 | + | if (!sameSpot({ session: at.session, window: at.window?.id ?? null }, spot)) continue; | |
| 275 | + | if (tab.active) return null; | |
| 276 | + | if (!best || SOURCE_RANK[hit.source] < SOURCE_RANK[best.source]) { | |
| 277 | + | best = { tabId: tab.id, source: hit.source }; | |
| 278 | + | } | |
| 279 | + | } | |
| 280 | + | return best; | |
| 281 | + | } | |
| 282 | + | ||
| 283 | + | /** | |
| 284 | + | * The pin as the terminal can actually act on it: the window if it is still | |
| 285 | + | * there, the session's own active window if it is not, and null when the whole | |
| 286 | + | * session has gone. | |
| 287 | + | * | |
| 288 | + | * A pin outliving its window is the normal case, not the broken one — you pin | |
| 289 | + | * the session you run the dev server in, then close and reopen the window it | |
| 290 | + | * was running in. Falling back to the session keeps that pin useful instead of | |
| 291 | + | * quietly dead. | |
| 292 | + | * | |
| 293 | + | * @param {TbPinTarget} target | |
| 294 | + | * @param {TbSessionInfo[]} sessions | |
| 295 | + | * @returns {{ session: string, window: TbWindowInfo | null } | null} | |
| 296 | + | */ | |
| 297 | + | function locate(target, sessions) { | |
| 298 | + | const s = sessions.find((x) => x.name === target.session); | |
| 299 | + | if (!s) return null; | |
| 300 | + | const w = target.window ? s.windows.find((x) => x.id === target.window) : null; | |
| 301 | + | return { session: s.name, window: w ?? null }; | |
| 302 | + | } | |
| 303 | + | ||
| 304 | + | /** | |
| 305 | + | * Set or clear one entry, returning a new store — the caller writes it to | |
| 306 | + | * storage, so nothing here mutates what is already live. | |
| 307 | + | * | |
| 308 | + | * `target` null writes the veto; undefined removes the entry entirely and lets | |
| 309 | + | * the layer below (an origin pin, then detection) answer again. | |
| 310 | + | * | |
| 311 | + | * @param {TbTabPinStore} store | |
| 312 | + | * @param {"tab" | "origin"} kind | |
| 313 | + | * @param {string | number} key | |
| 314 | + | * @param {TbPinTarget | null | undefined} target | |
| 315 | + | * @returns {TbTabPinStore} | |
| 316 | + | */ | |
| 317 | + | function put(store, kind, key, target) { | |
| 318 | + | const next = { | |
| 319 | + | ...store, | |
| 320 | + | byTab: { ...store.byTab }, | |
| 321 | + | byOrigin: { ...store.byOrigin }, | |
| 322 | + | }; | |
| 323 | + | const map = kind === "tab" ? next.byTab : next.byOrigin; | |
| 324 | + | if (target === undefined) delete map[String(key)]; | |
| 325 | + | else map[String(key)] = target; | |
| 326 | + | return next; | |
| 327 | + | } | |
| 328 | + | ||
| 329 | + | /** | |
| 330 | + | * Drop tab pins for tabs that no longer exist. | |
| 331 | + | * | |
| 332 | + | * Tab ids are per-run, so without this the map grows by one entry every time a | |
| 333 | + | * pinned tab is closed and never shrinks. Origin pins are left alone: they are | |
| 334 | + | * names, and a name whose session is not running today may be running tomorrow. | |
| 335 | + | * | |
| 336 | + | * @param {TbTabPinStore} store | |
| 337 | + | * @param {number[]} liveTabIds | |
| 338 | + | * @returns {TbTabPinStore | null} null when nothing needed dropping | |
| 339 | + | */ | |
| 340 | + | function pruneTabs(store, liveTabIds) { | |
| 341 | + | const live = new Set(liveTabIds.map(String)); | |
| 342 | + | const stale = Object.keys(store.byTab).filter((id) => !live.has(id)); | |
| 343 | + | if (!stale.length) return null; | |
| 344 | + | const next = { ...store, byTab: { ...store.byTab }, byOrigin: { ...store.byOrigin } }; | |
| 345 | + | for (const id of stale) delete next.byTab[id]; | |
| 346 | + | return next; | |
| 347 | + | } | |
| 348 | + | ||
| 349 | + | /** | |
| 350 | + | * Whether two places in tmux are the same place. | |
| 351 | + | * | |
| 352 | + | * A target with no window names a whole session, so the comparison stops at the | |
| 353 | + | * session name — "the session, wherever it is currently pointed" is satisfied by | |
| 354 | + | * any window of it. | |
| 355 | + | * | |
| 356 | + | * @param {TbSpot | null} a | |
| 357 | + | * @param {TbSpot | null} b | |
| 358 | + | */ | |
| 359 | + | function sameSpot(a, b) { | |
| 360 | + | if (!a || !b) return false; | |
| 361 | + | if (a.session !== b.session) return false; | |
| 362 | + | if (!a.window || !b.window) return true; | |
| 363 | + | return a.window === b.window; | |
| 364 | + | } | |
| 365 | + | ||
| 366 | + | /** | |
| 367 | + | * The whole state machine for "where should the terminal be, and what do we owe | |
| 368 | + | * it afterwards" — one step per browser tab change. | |
| 369 | + | * | |
| 370 | + | * A pin moving the terminal is an excursion, not a relocation. Leaving for a tab | |
| 371 | + | * with no pin of its own puts it back where it was before the first pinned tab | |
| 372 | + | * took it, so flicking between a pinned tab and an unpinned one flicks the | |
| 373 | + | * terminal between the two places rather than stranding it on the pinned one. | |
| 374 | + | * | |
| 375 | + | * Three things make that safe to do automatically: | |
| 376 | + | * | |
| 377 | + | * - Only the *first* pin in a run records a return. Pinned tab to pinned tab | |
| 378 | + | * to unpinned goes back to where the run started, not to the middle of it, | |
| 379 | + | * because the middle was never somewhere the user chose to be. | |
| 380 | + | * - Nothing is owed when the pin had nothing to do. Landing on a tab pinned | |
| 381 | + | * to where you already are records no return, so leaving it moves nothing. | |
| 382 | + | * - A terminal that has been moved by hand since is left alone. The return is | |
| 383 | + | * an undo of a move this code made, and once that move is gone there is | |
| 384 | + | * nothing to undo — dragging the user back from somewhere they steered to | |
| 385 | + | * themselves would be the panel overruling them. | |
| 386 | + | * | |
| 387 | + | * @param {TbPinReturn | null} owed what an earlier pin move left outstanding | |
| 388 | + | * @param {TbSpot | null} target where the showing tab points, null if nowhere | |
| 389 | + | * @param {TbSpot} at where the terminal is now | |
| 390 | + | * @returns {{ go: TbSpot | null, owed: TbPinReturn | null }} where to move it, | |
| 391 | + | * and what is outstanding afterwards | |
| 392 | + | */ | |
| 393 | + | function step(owed, target, at) { | |
| 394 | + | if (!target) { | |
| 395 | + | // Unpinned. Hand the terminal back if the excursion is still standing. | |
| 396 | + | if (owed && sameSpot(at, owed.to)) return { go: owed.from, owed: null }; | |
| 397 | + | return { go: null, owed: null }; | |
| 398 | + | } | |
| 399 | + | if (sameSpot(at, target)) { | |
| 400 | + | // Already there. An outstanding return survives — this tab is part of the | |
| 401 | + | // same excursion — but a new one is not opened, because nothing moved. | |
| 402 | + | return { go: null, owed: owed ? { ...owed, to: target } : null }; | |
| 403 | + | } | |
| 404 | + | return { go: target, owed: owed ? { ...owed, to: target } : { from: at, to: target } }; | |
| 405 | + | } | |
| 406 | + | ||
| 407 | + | /** | |
| 408 | + | * How the pin reads in a menu or a tooltip. | |
| 409 | + | * | |
| 410 | + | * @param {TbPinTarget} target | |
| 411 | + | * @returns {string} | |
| 412 | + | */ | |
| 413 | + | function describe(target) { | |
| 414 | + | return target.window ? `${target.session} (one window)` : target.session; | |
| 415 | + | } | |
| 416 | + | ||
| 417 | + | const Tabpin = { | |
| 418 | + | KEY: TABPIN_KEY, | |
| 419 | + | emptyStore, | |
| 420 | + | loadStore, | |
| 421 | + | originOf, | |
| 422 | + | localPort, | |
| 423 | + | baseName, | |
| 424 | + | detectTarget, | |
| 425 | + | resolve, | |
| 426 | + | reverse, | |
| 427 | + | locate, | |
| 428 | + | put, | |
| 429 | + | pruneTabs, | |
| 430 | + | sameSpot, | |
| 431 | + | step, | |
| 432 | + | describe, | |
| 433 | + | }; | |
| 434 | + | ||
| 435 | + | if (typeof module !== "undefined" && module.exports) { | |
| 436 | + | module.exports = Tabpin; | |
| 437 | + | } |
addedextension/lib/tabpin.test.js+327 −0
| 1 | + | // Run with: node --test "extension/lib/*.test.js" | |
| 2 | + | // | |
| 3 | + | // What matters here is precedence. Four rules can answer "where does this tab | |
| 4 | + | // send the terminal" — a tab pin, an origin pin, a veto and a devport guess — | |
| 5 | + | // and getting their order wrong means the panel moves somewhere the user did | |
| 6 | + | // not ask for, which is the one failure this feature cannot afford. | |
| 7 | + | ||
| 8 | + | const test = require("node:test"); | |
| 9 | + | const assert = require("node:assert"); | |
| 10 | + | ||
| 11 | + | const Tabpin = require("./tabpin.js"); | |
| 12 | + | const Devport = require("./devport.js"); | |
| 13 | + | ||
| 14 | + | /** @param {string} name */ | |
| 15 | + | const session = (name, path, windows = []) => ({ | |
| 16 | + | id: `$${name}`, | |
| 17 | + | name, | |
| 18 | + | attached: false, | |
| 19 | + | path, | |
| 20 | + | windows: windows.map((id, i) => ({ | |
| 21 | + | id, | |
| 22 | + | index: i + 1, | |
| 23 | + | name: id, | |
| 24 | + | active: i === 0, | |
| 25 | + | panes: 1, | |
| 26 | + | activity: false, | |
| 27 | + | })), | |
| 28 | + | }); | |
| 29 | + | ||
| 30 | + | const SESSIONS = [ | |
| 31 | + | session("web", "/home/u/Code/browser-terminal-extension", ["@1", "@2"]), | |
| 32 | + | session("dots", "/home/u/Code/dotfiles", ["@7"]), | |
| 33 | + | ]; | |
| 34 | + | ||
| 35 | + | /** The port devport hands the first session's directory. */ | |
| 36 | + | const WEB_PORT = Devport.portOf("browser-terminal-extension"); | |
| 37 | + | ||
| 38 | + | const store = (over = {}) => ({ ...Tabpin.emptyStore(), ...over }); | |
| 39 | + | const resolve = (s, tab, sessions = SESSIONS) => Tabpin.resolve(s, tab, sessions, Devport); | |
| 40 | + | ||
| 41 | + | test("an unknown tab moves nothing", () => { | |
| 42 | + | assert.equal(resolve(store(), { id: 1, url: "https://example.com/" }), null); | |
| 43 | + | }); | |
| 44 | + | ||
| 45 | + | test("an origin pin matches any tab on that origin", () => { | |
| 46 | + | const s = store({ byOrigin: { "https://example.com": { session: "web", window: null } } }); | |
| 47 | + | // Path and query are not part of the key. | |
| 48 | + | const hit = resolve(s, { id: 9, url: "https://example.com/deep/page?q=1" }); | |
| 49 | + | assert.deepEqual(hit, { target: { session: "web", window: null }, source: "origin" }); | |
| 50 | + | // A different port is a different origin, and so not a match. | |
| 51 | + | assert.equal(resolve(s, { id: 9, url: "https://example.com:8443/" }), null); | |
| 52 | + | }); | |
| 53 | + | ||
| 54 | + | test("a tab pin outranks the origin pin under it", () => { | |
| 55 | + | const s = store({ | |
| 56 | + | byTab: { 5: { session: "dots", window: "@7" } }, | |
| 57 | + | byOrigin: { "https://example.com": { session: "web", window: null } }, | |
| 58 | + | }); | |
| 59 | + | assert.equal(resolve(s, { id: 5, url: "https://example.com/" }).source, "tab"); | |
| 60 | + | assert.equal(resolve(s, { id: 6, url: "https://example.com/" }).source, "origin"); | |
| 61 | + | }); | |
| 62 | + | ||
| 63 | + | test("a null entry is a veto and stops the search", () => { | |
| 64 | + | const s = store({ | |
| 65 | + | byTab: { 5: null }, | |
| 66 | + | byOrigin: { [`http://localhost:${WEB_PORT}`]: { session: "web", window: null } }, | |
| 67 | + | }); | |
| 68 | + | // The origin pin below it would have matched; the veto is why it does not. | |
| 69 | + | assert.equal(resolve(s, { id: 5, url: `http://localhost:${WEB_PORT}/` }), null); | |
| 70 | + | // And an origin veto is what turns a devport guess off for good. | |
| 71 | + | const vetoed = store({ byOrigin: { [`http://localhost:${WEB_PORT}`]: null } }); | |
| 72 | + | assert.equal(resolve(vetoed, { id: 5, url: `http://localhost:${WEB_PORT}/` }), null); | |
| 73 | + | }); | |
| 74 | + | ||
| 75 | + | test("a localhost devport port finds its session with nothing stored", () => { | |
| 76 | + | const hit = resolve(store(), { id: 1, url: `http://localhost:${WEB_PORT}/app` }); | |
| 77 | + | assert.deepEqual(hit, { target: { session: "web", window: null }, source: "detect" }); | |
| 78 | + | // Every port in the block, not just the base one. | |
| 79 | + | assert.equal(resolve(store(), { id: 1, url: `http://127.0.0.1:${WEB_PORT + 3}/` }).source, "detect"); | |
| 80 | + | }); | |
| 81 | + | ||
| 82 | + | test("detection is local, in-range and unambiguous or it does not fire", () => { | |
| 83 | + | const off = store({ detect: false }); | |
| 84 | + | assert.equal(resolve(off, { id: 1, url: `http://localhost:${WEB_PORT}/` }), null); | |
| 85 | + | // A remote host on the same port says nothing about your projects. | |
| 86 | + | assert.equal(resolve(store(), { id: 1, url: `http://example.com:${WEB_PORT}/` }), null); | |
| 87 | + | // A hand-picked port is in nobody's block. | |
| 88 | + | assert.equal(resolve(store(), { id: 1, url: "http://localhost:3000/" }), null); | |
| 89 | + | // Two sessions in the same block is a collision, and a coin toss is worse | |
| 90 | + | // than doing nothing. | |
| 91 | + | const twins = [...SESSIONS, session("web2", "/elsewhere/browser-terminal-extension", ["@9"])]; | |
| 92 | + | assert.equal(resolve(store(), { id: 1, url: `http://localhost:${WEB_PORT}/` }, twins), null); | |
| 93 | + | }); | |
| 94 | + | ||
| 95 | + | test("the session name is a candidate too, not just its directory", () => { | |
| 96 | + | const named = [session("myapp", "/home/u/somewhere/else", ["@1"])]; | |
| 97 | + | const port = Devport.portOf("myapp"); | |
| 98 | + | assert.equal(resolve(store(), { id: 1, url: `http://localhost:${port}/` }, named).source, "detect"); | |
| 99 | + | }); | |
| 100 | + | ||
| 101 | + | test("the whole feature can be switched off without losing pins", () => { | |
| 102 | + | const s = store({ | |
| 103 | + | enabled: false, | |
| 104 | + | byOrigin: { "https://example.com": { session: "web", window: null } }, | |
| 105 | + | }); | |
| 106 | + | assert.equal(resolve(s, { id: 1, url: "https://example.com/" }), null); | |
| 107 | + | assert.equal(resolve({ ...s, enabled: true }, { id: 1, url: "https://example.com/" }).source, "origin"); | |
| 108 | + | }); | |
| 109 | + | ||
| 110 | + | test("only http and https can carry an origin pin", () => { | |
| 111 | + | for (const url of ["file:///tmp/x.html", "about:blank", "chrome://extensions", undefined]) { | |
| 112 | + | assert.equal(Tabpin.originOf(url), ""); | |
| 113 | + | } | |
| 114 | + | assert.equal(Tabpin.originOf("http://localhost:26210/x"), "http://localhost:26210"); | |
| 115 | + | }); | |
| 116 | + | ||
| 117 | + | test("locate falls back to the session when the window has closed", () => { | |
| 118 | + | assert.deepEqual(Tabpin.locate({ session: "web", window: "@2" }, SESSIONS).window.id, "@2"); | |
| 119 | + | // Pinned to a window that has since been killed: still the right session. | |
| 120 | + | const gone = Tabpin.locate({ session: "web", window: "@99" }, SESSIONS); | |
| 121 | + | assert.deepEqual({ session: gone.session, window: gone.window }, { session: "web", window: null }); | |
| 122 | + | // Session gone entirely: nothing to act on. | |
| 123 | + | assert.equal(Tabpin.locate({ session: "ghost", window: null }, SESSIONS), null); | |
| 124 | + | }); | |
| 125 | + | ||
| 126 | + | test("loadStore drops anything that is not a pin", () => { | |
| 127 | + | const s = Tabpin.loadStore({ | |
| 128 | + | enabled: false, | |
| 129 | + | byTab: { 1: { session: "web", window: "@1" }, 2: { window: "@2" }, 3: "web", 4: null }, | |
| 130 | + | byOrigin: "not a map", | |
| 131 | + | }); | |
| 132 | + | assert.equal(s.enabled, false); | |
| 133 | + | // detect was absent, and absent is not off. | |
| 134 | + | assert.equal(s.detect, true); | |
| 135 | + | assert.deepEqual(s.byTab, { 1: { session: "web", window: "@1" }, 4: null }); | |
| 136 | + | assert.deepEqual(s.byOrigin, {}); | |
| 137 | + | // Junk in, empty out — never a throw, because this is storage a past version | |
| 138 | + | // of the panel wrote. | |
| 139 | + | assert.deepEqual(Tabpin.loadStore(null), Tabpin.emptyStore()); | |
| 140 | + | assert.deepEqual(Tabpin.loadStore("x"), Tabpin.emptyStore()); | |
| 141 | + | }); | |
| 142 | + | ||
| 143 | + | test("put does not mutate the store it was given", () => { | |
| 144 | + | const before = store(); | |
| 145 | + | const after = Tabpin.put(before, "origin", "https://example.com", { session: "web" }); | |
| 146 | + | assert.deepEqual(before.byOrigin, {}); | |
| 147 | + | assert.deepEqual(after.byOrigin, { "https://example.com": { session: "web" } }); | |
| 148 | + | assert.deepEqual(Tabpin.put(after, "origin", "https://example.com", undefined).byOrigin, {}); | |
| 149 | + | assert.deepEqual(Tabpin.put(after, "origin", "https://example.com", null).byOrigin, { | |
| 150 | + | "https://example.com": null, | |
| 151 | + | }); | |
| 152 | + | }); | |
| 153 | + | ||
| 154 | + | test("pruneTabs drops closed tabs and leaves origins alone", () => { | |
| 155 | + | const s = store({ | |
| 156 | + | byTab: { 1: { session: "web" }, 2: { session: "dots" } }, | |
| 157 | + | byOrigin: { "https://example.com": { session: "web" } }, | |
| 158 | + | }); | |
| 159 | + | assert.equal(Tabpin.pruneTabs(s, [1, 2]), null); | |
| 160 | + | const pruned = Tabpin.pruneTabs(s, [2]); | |
| 161 | + | assert.deepEqual(Object.keys(pruned.byTab), ["2"]); | |
| 162 | + | assert.deepEqual(pruned.byOrigin, s.byOrigin); | |
| 163 | + | }); | |
| 164 | + | ||
| 165 | + | // --- the excursion ---------------------------------------------------------- | |
| 166 | + | // | |
| 167 | + | // A pin borrows the terminal; leaving the pinned tab gives it back. These are | |
| 168 | + | // the rules that decide when it is owed and when the debt has lapsed. | |
| 169 | + | ||
| 170 | + | const AT = { session: "home", window: "@1" }; | |
| 171 | + | const PIN = { session: "web", window: "@2" }; | |
| 172 | + | ||
| 173 | + | test("a pin borrows the terminal and an unpinned tab hands it back", () => { | |
| 174 | + | const first = Tabpin.step(null, PIN, AT); | |
| 175 | + | assert.deepEqual(first.go, PIN); | |
| 176 | + | assert.deepEqual(first.owed, { from: AT, to: PIN }); | |
| 177 | + | ||
| 178 | + | // Now sitting where the pin put us, and the browser moves to a plain tab. | |
| 179 | + | const back = Tabpin.step(first.owed, null, PIN); | |
| 180 | + | assert.deepEqual(back.go, AT); | |
| 181 | + | assert.equal(back.owed, null); | |
| 182 | + | }); | |
| 183 | + | ||
| 184 | + | test("flicking back and forth flicks the terminal with it", () => { | |
| 185 | + | let owed = null; | |
| 186 | + | let at = AT; | |
| 187 | + | for (let i = 0; i < 3; i++) { | |
| 188 | + | ({ go: at, owed } = trip(owed, PIN, at)); | |
| 189 | + | assert.deepEqual(at, PIN, `to the pinned tab, round ${i}`); | |
| 190 | + | ({ go: at, owed } = trip(owed, null, at)); | |
| 191 | + | assert.deepEqual(at, AT, `and back, round ${i}`); | |
| 192 | + | } | |
| 193 | + | // `go` null means "stay", so the walk needs the previous place carried over. | |
| 194 | + | function trip(o, target, from) { | |
| 195 | + | const r = Tabpin.step(o, target, from); | |
| 196 | + | return { go: r.go ?? from, owed: r.owed }; | |
| 197 | + | } | |
| 198 | + | }); | |
| 199 | + | ||
| 200 | + | test("a run of pinned tabs returns to where the run started", () => { | |
| 201 | + | const one = Tabpin.step(null, PIN, AT); | |
| 202 | + | const other = { session: "dots", window: "@7" }; | |
| 203 | + | const two = Tabpin.step(one.owed, other, PIN); | |
| 204 | + | assert.deepEqual(two.go, other); | |
| 205 | + | // The middle of the excursion is not somewhere the user chose to be. | |
| 206 | + | assert.deepEqual(two.owed, { from: AT, to: other }); | |
| 207 | + | assert.deepEqual(Tabpin.step(two.owed, null, other).go, AT); | |
| 208 | + | }); | |
| 209 | + | ||
| 210 | + | test("a pin that had nothing to do owes nothing", () => { | |
| 211 | + | // Landing on a tab pinned to where you already are. | |
| 212 | + | const r = Tabpin.step(null, PIN, PIN); | |
| 213 | + | assert.equal(r.go, null); | |
| 214 | + | assert.equal(r.owed, null); | |
| 215 | + | // So leaving it moves nothing either. | |
| 216 | + | assert.deepEqual(Tabpin.step(r.owed, null, PIN), { go: null, owed: null }); | |
| 217 | + | }); | |
| 218 | + | ||
| 219 | + | test("a switch made by hand cancels the return rather than being undone", () => { | |
| 220 | + | const { owed } = Tabpin.step(null, PIN, AT); | |
| 221 | + | // The user steered somewhere else while sitting on the pinned tab. | |
| 222 | + | const steered = { session: "web", window: "@9" }; | |
| 223 | + | const r = Tabpin.step(owed, null, steered); | |
| 224 | + | assert.equal(r.go, null, "the panel does not overrule a by-hand switch"); | |
| 225 | + | assert.equal(r.owed, null, "and the debt lapses rather than lingering"); | |
| 226 | + | }); | |
| 227 | + | ||
| 228 | + | test("a session-level pin is satisfied by any window of that session", () => { | |
| 229 | + | const session = { session: "web", window: null }; | |
| 230 | + | // Already in the session, on some window of it: nothing to do. | |
| 231 | + | assert.deepEqual(Tabpin.step(null, session, { session: "web", window: "@5" }), { | |
| 232 | + | go: null, | |
| 233 | + | owed: null, | |
| 234 | + | }); | |
| 235 | + | // And the return still fires after moving around inside it. | |
| 236 | + | const { owed } = Tabpin.step(null, session, AT); | |
| 237 | + | assert.deepEqual(Tabpin.step(owed, null, { session: "web", window: "@5" }).go, AT); | |
| 238 | + | }); | |
| 239 | + | ||
| 240 | + | test("sameSpot compares by session, and by window only when both name one", () => { | |
| 241 | + | assert.ok(Tabpin.sameSpot({ session: "a", window: "@1" }, { session: "a", window: "@1" })); | |
| 242 | + | assert.ok(Tabpin.sameSpot({ session: "a", window: "@1" }, { session: "a", window: null })); | |
| 243 | + | assert.ok(!Tabpin.sameSpot({ session: "a", window: "@1" }, { session: "a", window: "@2" })); | |
| 244 | + | assert.ok(!Tabpin.sameSpot({ session: "a" }, { session: "b" })); | |
| 245 | + | assert.ok(!Tabpin.sameSpot(null, { session: "a" })); | |
| 246 | + | }); | |
| 247 | + | ||
| 248 | + | test("baseName reads the directory devport would have hashed", () => { | |
| 249 | + | assert.equal(Tabpin.baseName("/home/u/Code/thing"), "thing"); | |
| 250 | + | assert.equal(Tabpin.baseName("/home/u/Code/thing/"), "thing"); | |
| 251 | + | assert.equal(Tabpin.baseName(null), ""); | |
| 252 | + | }); | |
| 253 | + | ||
| 254 | + | // --- the other direction ---------------------------------------------------- | |
| 255 | + | // | |
| 256 | + | // `reverse` is the same rules read from the far end, so what these check is | |
| 257 | + | // mostly that it agrees with `resolve`: the tab a pin would have chosen going | |
| 258 | + | // one way is the tab it chooses coming back, vetoes still veto, and the more | |
| 259 | + | // specific rule still wins. Plus the one rule that is only its own — a browser | |
| 260 | + | // already showing a tab that points here is left alone, which is what keeps the | |
| 261 | + | // two directions from chasing each other. | |
| 262 | + | ||
| 263 | + | const rev = (s, spot, tabs, sessions = SESSIONS) => Tabpin.reverse(s, spot, tabs, sessions, Devport); | |
| 264 | + | ||
| 265 | + | test("reverse finds the tab an origin pin names", () => { | |
| 266 | + | const s = store({ byOrigin: { "https://example.com": { session: "web", window: null } } }); | |
| 267 | + | const tabs = [ | |
| 268 | + | { id: 1, url: "https://other.test/", active: true }, | |
| 269 | + | { id: 2, url: "https://example.com/deep" }, | |
| 270 | + | ]; | |
| 271 | + | assert.deepEqual(rev(s, { session: "web", window: "@1" }, tabs), { tabId: 2, source: "origin" }); | |
| 272 | + | // Nothing points at the other session, so nothing moves. | |
| 273 | + | assert.equal(rev(s, { session: "dots", window: "@7" }, tabs), null); | |
| 274 | + | }); | |
| 275 | + | ||
| 276 | + | test("reverse does nothing when the showing tab already points here", () => { | |
| 277 | + | const s = store({ byOrigin: { "https://example.com": { session: "web", window: null } } }); | |
| 278 | + | const tabs = [ | |
| 279 | + | { id: 2, url: "https://example.com/", active: true }, | |
| 280 | + | { id: 3, url: "https://example.com/other" }, | |
| 281 | + | ]; | |
| 282 | + | // Two tabs match and one of them is showing: that one wins outright, so the | |
| 283 | + | // browser is left where it is rather than being dragged to the other. | |
| 284 | + | assert.equal(rev(s, { session: "web", window: "@1" }, tabs), null); | |
| 285 | + | }); | |
| 286 | + | ||
| 287 | + | test("reverse prefers the more specific rule, then the more recent tab", () => { | |
| 288 | + | const s = store({ | |
| 289 | + | byTab: { 9: { session: "web", window: null } }, | |
| 290 | + | byOrigin: { "https://example.com": { session: "web", window: null } }, | |
| 291 | + | }); | |
| 292 | + | const tabs = [ | |
| 293 | + | { id: 4, url: "https://example.com/" }, | |
| 294 | + | { id: 9, url: "https://elsewhere.test/" }, | |
| 295 | + | ]; | |
| 296 | + | // The tab pin outranks the origin pin even though its tab is later in the | |
| 297 | + | // list, exactly as it does going forward. | |
| 298 | + | assert.deepEqual(rev(s, { session: "web" }, tabs), { tabId: 9, source: "tab" }); | |
| 299 | + | // Between two tabs answering by the same rule, the caller's order decides — | |
| 300 | + | // it sorts by last use, so this is the one you were reading. | |
| 301 | + | const origins = store({ byOrigin: { "https://example.com": { session: "web", window: null } } }); | |
| 302 | + | assert.equal(rev(origins, { session: "web" }, tabs).tabId, 4); | |
| 303 | + | }); | |
| 304 | + | ||
| 305 | + | test("reverse honours a veto and the off switches", () => { | |
| 306 | + | const tabs = [{ id: 5, url: `http://localhost:${WEB_PORT}/` }]; | |
| 307 | + | const spot = { session: "web", window: "@1" }; | |
| 308 | + | // The devport guess would have matched this tab. | |
| 309 | + | assert.equal(rev(store(), spot, tabs).source, "detect"); | |
| 310 | + | // An origin veto turns it off from this end too. | |
| 311 | + | assert.equal(rev(store({ byOrigin: { [`http://localhost:${WEB_PORT}`]: null } }), spot, tabs), null); | |
| 312 | + | // As does either switch. | |
| 313 | + | assert.equal(rev(store({ reverse: false }), spot, tabs), null); | |
| 314 | + | assert.equal(rev(store({ enabled: false }), spot, tabs), null); | |
| 315 | + | assert.equal(rev(store({ detect: false }), spot, tabs), null); | |
| 316 | + | }); | |
| 317 | + | ||
| 318 | + | test("reverse follows a window pin whose window has closed to its session", () => { | |
| 319 | + | const s = store({ byOrigin: { "https://example.com": { session: "web", window: "@404" } } }); | |
| 320 | + | const tabs = [{ id: 2, url: "https://example.com/" }]; | |
| 321 | + | // The pin names a window that is gone, so it stands for its session — the | |
| 322 | + | // same fallback `locate` makes going forward — and any window of it matches. | |
| 323 | + | assert.deepEqual(rev(s, { session: "web", window: "@2" }, tabs), { tabId: 2, source: "origin" }); | |
| 324 | + | // A pin to a session that is not running at all still resolves to nothing. | |
| 325 | + | const dead = store({ byOrigin: { "https://example.com": { session: "gone", window: null } } }); | |
| 326 | + | assert.equal(rev(dead, { session: "gone", window: null }, tabs), null); | |
| 327 | + | }); |
modifiedextension/mock.html+10 −2
| ⋯ 17 unchanged lines | |||
| 18 | 18 | <body> | |
| 19 | 19 | <script src="lib/theme.js"></script> | |
| 20 | 20 | <script> | |
| 21 | + | // ?light renders the light palette; the default is dark. | |
| 21 | 22 | const dark = matchMedia("(prefers-color-scheme: dark)").matches; | |
| 22 | - | const t = Themes.resolveTheme("dark", dark); | |
| 23 | + | const t = Themes.resolveTheme( | |
| 24 | + | location.search.includes("light") ? "light" : "dark", dark); | |
| 23 | 25 | for (const [k, v] of Object.entries(t.ui)) document.documentElement.style.setProperty(k, v); | |
| 24 | 26 | ||
| 25 | 27 | // ?full fills the row; the default is one of each, so both layouts can be | |
| ⋯ 160 unchanged lines | |||
| 186 | 188 | const toggle = document.createElement("button"); | |
| 187 | 189 | toggle.id = "mode"; | |
| 188 | 190 | toggle.textContent = "switch to tab groups"; | |
| 189 | - | let mode = "nested"; | |
| 191 | + | // ?groups starts in the other layout, so a screenshot can be taken of | |
| 192 | + | // it without a click. | |
| 193 | + | let mode = location.search.includes("groups") ? "groups" : "nested"; | |
| 194 | + | if (mode === "groups") { | |
| 195 | + | toggle.textContent = "switch to nested tabs"; | |
| 196 | + | setMode(mode); | |
| 197 | + | } | |
| 190 | 198 | toggle.addEventListener("click", () => { | |
| 191 | 199 | mode = mode === "nested" ? "groups" : "nested"; | |
| 192 | 200 | toggle.textContent = `switch to ${mode === "nested" ? "tab groups" : "nested tabs"}`; | |
| ⋯ 7 unchanged lines | |||
addedextension/page.js+198 −0
| 1 | + | // Reading the current page as text, so it can be handed to an agent in the pty. | |
| 2 | + | // | |
| 3 | + | // Same contract as picker.js: this function is serialised and executed in the | |
| 4 | + | // page's world via scripting.executeScript, so it must be entirely | |
| 5 | + | // self-contained — no imports, no closures over extension state, no extension | |
| 6 | + | // privileges. What it returns comes back as executeScript's *return value*, | |
| 7 | + | // which is why the sidebar needs no inbound message listener a content script | |
| 8 | + | // could reach. | |
| 9 | + | // | |
| 10 | + | // Everything it returns is page-controlled data. It is not text until | |
| 11 | + | // Sanitize.forPasteBlock has been over it — see sidebar.js. | |
| 12 | + | ||
| 13 | + | /** | |
| 14 | + | * @param {number} max hard cap on how many characters of body text to return | |
| 15 | + | */ | |
| 16 | + | function tbReadPage(max) { | |
| 17 | + | // Elements that are never prose. SVG and CANVAS carry no readable text; | |
| 18 | + | // SCRIPT and STYLE carry text that is not prose; the rest are chrome. | |
| 19 | + | const SKIP = new Set([ | |
| 20 | + | "SCRIPT", | |
| 21 | + | "STYLE", | |
| 22 | + | "NOSCRIPT", | |
| 23 | + | "TEMPLATE", | |
| 24 | + | "IFRAME", | |
| 25 | + | "SVG", | |
| 26 | + | "CANVAS", | |
| 27 | + | "AUDIO", | |
| 28 | + | "VIDEO", | |
| 29 | + | "SELECT", | |
| 30 | + | "OPTION", | |
| 31 | + | "TEXTAREA", | |
| 32 | + | ]); | |
| 33 | + | ||
| 34 | + | // Page furniture: dropped only when we are reading the whole document, since | |
| 35 | + | // then they are the site's navigation rather than the article. Inside a | |
| 36 | + | // <main> or <article> they are the author's own structure and stay. | |
| 37 | + | const FURNITURE = new Set(["NAV", "HEADER", "FOOTER", "ASIDE"]); | |
| 38 | + | ||
| 39 | + | // Anything that ends a line of prose. Used two ways: to know which elements | |
| 40 | + | // produce a block, and — as a selector — to know whether an element is a | |
| 41 | + | // container to recurse into or a leaf to read. | |
| 42 | + | const BLOCK = | |
| 43 | + | "p,h1,h2,h3,h4,h5,h6,li,pre,blockquote,figcaption,dt,dd,td,th,tr,table,ul,ol,article,section,main,div"; | |
| 44 | + | ||
| 45 | + | /** @param {string} s */ | |
| 46 | + | const squash = (s) => s.replace(/\s+/g, " ").trim(); | |
| 47 | + | ||
| 48 | + | /** | |
| 49 | + | * Rendered, in the sense that matters here: it has boxes. Catches | |
| 50 | + | * `display: none`, `hidden`, and the zero-size wrappers a framework leaves | |
| 51 | + | * behind, without a getComputedStyle call per element. | |
| 52 | + | * | |
| 53 | + | * @param {Element} el | |
| 54 | + | */ | |
| 55 | + | function rendered(el) { | |
| 56 | + | if (el.getClientRects().length) return true; | |
| 57 | + | // A <tr> in a table with `display: block` cells, and other layout oddities, | |
| 58 | + | // can report no rects while its contents are plainly visible. Fall back to | |
| 59 | + | // the cheap style read rather than dropping real text. | |
| 60 | + | const cs = el.ownerDocument.defaultView?.getComputedStyle(el); | |
| 61 | + | return !cs || (cs.display !== "none" && cs.visibility !== "hidden"); | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | /** @type {string[]} */ | |
| 65 | + | const lines = []; | |
| 66 | + | let chars = 0; | |
| 67 | + | let full = true; | |
| 68 | + | ||
| 69 | + | /** @param {string} line */ | |
| 70 | + | function emit(line) { | |
| 71 | + | const text = line.trimEnd(); | |
| 72 | + | if (!text) return; | |
| 73 | + | // Site furniture repeats itself — a heading that is also the link that got | |
| 74 | + | // you here, a label rendered twice at two breakpoints. Consecutive | |
| 75 | + | // duplicates are always noise. | |
| 76 | + | if (lines[lines.length - 1] === text) return; | |
| 77 | + | if (chars + text.length > max) { | |
| 78 | + | full = false; | |
| 79 | + | return; | |
| 80 | + | } | |
| 81 | + | lines.push(text); | |
| 82 | + | chars += text.length + 1; | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | /** | |
| 86 | + | * Text of an element, minus anything that would be read as its own block. | |
| 87 | + | * A list item's own words without its nested list, a cell's without a table | |
| 88 | + | * inside it. | |
| 89 | + | * | |
| 90 | + | * @param {Element} el | |
| 91 | + | */ | |
| 92 | + | function ownText(el) { | |
| 93 | + | let out = ""; | |
| 94 | + | for (const node of el.childNodes) { | |
| 95 | + | if (node.nodeType === 3) out += node.nodeValue ?? ""; | |
| 96 | + | else if (node.nodeType === 1) { | |
| 97 | + | const child = /** @type {Element} */ (node); | |
| 98 | + | if (SKIP.has(child.tagName)) continue; | |
| 99 | + | if (child.matches("ul,ol,table,pre,p,li,h1,h2,h3,h4,h5,h6,blockquote")) continue; | |
| 100 | + | out += ownText(child); | |
| 101 | + | } | |
| 102 | + | } | |
| 103 | + | return squash(out); | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | /** | |
| 107 | + | * @param {Element} el | |
| 108 | + | * @param {boolean} trimFurniture whether NAV/HEADER/FOOTER/ASIDE are chrome | |
| 109 | + | */ | |
| 110 | + | function walk(el, trimFurniture) { | |
| 111 | + | if (!full) return; | |
| 112 | + | const tag = el.tagName; | |
| 113 | + | if (SKIP.has(tag)) return; | |
| 114 | + | if (trimFurniture && FURNITURE.has(tag)) return; | |
| 115 | + | if (el.getAttribute("aria-hidden") === "true") return; | |
| 116 | + | if (!rendered(el)) return; | |
| 117 | + | ||
| 118 | + | if (/^H[1-6]$/.test(tag)) { | |
| 119 | + | emit(`${"#".repeat(Number(tag[1]))} ${squash(el.textContent ?? "")}`); | |
| 120 | + | return; | |
| 121 | + | } | |
| 122 | + | if (tag === "PRE") { | |
| 123 | + | const code = (el.textContent ?? "").replace(/\s+$/, ""); | |
| 124 | + | if (code) emit(`\`\`\`\n${code}\n\`\`\``); | |
| 125 | + | return; | |
| 126 | + | } | |
| 127 | + | if (tag === "LI") { | |
| 128 | + | emit(`- ${ownText(el)}`); | |
| 129 | + | // The nested list is its own set of items, so it is walked rather than | |
| 130 | + | // folded into the line above. | |
| 131 | + | for (const child of el.children) { | |
| 132 | + | if (child.matches("ul,ol,table,pre")) walk(child, trimFurniture); | |
| 133 | + | } | |
| 134 | + | return; | |
| 135 | + | } | |
| 136 | + | if (tag === "TR") { | |
| 137 | + | /** @type {string[]} */ | |
| 138 | + | const cells = []; | |
| 139 | + | for (const cell of el.children) cells.push(ownText(cell)); | |
| 140 | + | if (cells.some(Boolean)) emit(cells.join(" | ")); | |
| 141 | + | return; | |
| 142 | + | } | |
| 143 | + | if (tag === "P" || tag === "BLOCKQUOTE" || tag === "FIGCAPTION" || tag === "DT" || tag === "DD") { | |
| 144 | + | emit(ownText(el)); | |
| 145 | + | return; | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | // Not a block of its own: either a container of blocks, which is walked, or | |
| 149 | + | // a leaf that happens to hold the text — a <div> or <span> with words in it | |
| 150 | + | // and no <p> in sight, which is most of the modern web. | |
| 151 | + | if (el.querySelector(BLOCK)) { | |
| 152 | + | for (const child of el.children) walk(child, trimFurniture); | |
| 153 | + | // Text sitting directly inside a container, alongside its blocks, would | |
| 154 | + | // otherwise be dropped. | |
| 155 | + | const own = ownText(el); | |
| 156 | + | if (own) emit(own); | |
| 157 | + | return; | |
| 158 | + | } | |
| 159 | + | emit(squash(el.textContent ?? "")); | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | /** @param {string} sel */ | |
| 163 | + | const meta = (sel) => squash(document.querySelector(sel)?.getAttribute("content") ?? ""); | |
| 164 | + | ||
| 165 | + | const selection = squash(window.getSelection()?.toString() ?? ""); | |
| 166 | + | ||
| 167 | + | if (selection) { | |
| 168 | + | // A selection is the user saying which part they meant, so it is taken | |
| 169 | + | // verbatim — no extraction, no furniture trimming, no reordering. | |
| 170 | + | const truncated = selection.length > max; | |
| 171 | + | return { | |
| 172 | + | url: location.href, | |
| 173 | + | title: document.title || "", | |
| 174 | + | description: meta('meta[name="description"]') || meta('meta[property="og:description"]'), | |
| 175 | + | kind: /** @type {"selection"} */ ("selection"), | |
| 176 | + | body: truncated ? selection.slice(0, max) : selection, | |
| 177 | + | chars: selection.length, | |
| 178 | + | truncated, | |
| 179 | + | }; | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | // The narrowest thing that plausibly holds the article. Falling back to | |
| 183 | + | // <body> is the common case; the trim of nav/header/footer only applies then, | |
| 184 | + | // because inside a <main> those tags are the author's own. | |
| 185 | + | const main = document.querySelector("main, article, [role='main']"); | |
| 186 | + | const root = main ?? document.body ?? document.documentElement; | |
| 187 | + | walk(root, !main); | |
| 188 | + | ||
| 189 | + | return { | |
| 190 | + | url: location.href, | |
| 191 | + | title: document.title || "", | |
| 192 | + | description: meta('meta[name="description"]') || meta('meta[property="og:description"]'), | |
| 193 | + | kind: /** @type {"page"} */ ("page"), | |
| 194 | + | body: lines.join("\n"), | |
| 195 | + | chars, | |
| 196 | + | truncated: !full, | |
| 197 | + | }; | |
| 198 | + | } |
modifiedextension/picker.js+39 −3
| ⋯ 43 unchanged lines | |||
| 44 | 44 | boxShadow: "0 2px 8px rgba(0,0,0,.4)", | |
| 45 | 45 | }); | |
| 46 | 46 | ||
| 47 | - | document.documentElement.append(box, label); | |
| 47 | + | // Escape only reaches a keydown listener here if this document holds the | |
| 48 | + | // keyboard focus. A page whose focus sits in a text field is fine, but one | |
| 49 | + | // that has handed focus to an iframe is not: key events go to the frame and | |
| 50 | + | // never surface in the top document. So the picker parks focus on a sink of | |
| 51 | + | // its own for as long as it is up, and puts it back on the way out. | |
| 52 | + | const sink = document.createElement("div"); | |
| 53 | + | Object.assign(sink.style, { | |
| 54 | + | position: "fixed", | |
| 55 | + | top: "0", | |
| 56 | + | left: "0", | |
| 57 | + | width: "0", | |
| 58 | + | height: "0", | |
| 59 | + | opacity: "0", | |
| 60 | + | outline: "none", | |
| 61 | + | pointerEvents: "none", | |
| 62 | + | }); | |
| 63 | + | sink.tabIndex = -1; | |
| 64 | + | ||
| 65 | + | document.documentElement.append(box, label, sink); | |
| 66 | + | ||
| 67 | + | const wasFocused = /** @type {Element | null} */ (document.activeElement); | |
| 68 | + | const grabFocus = () => { | |
| 69 | + | if (document.activeElement !== sink) sink.focus({ preventScroll: true }); | |
| 70 | + | }; | |
| 71 | + | grabFocus(); | |
| 48 | 72 | ||
| 49 | 73 | /** Element under the cursor, so a click resolves what the box is drawn on. */ | |
| 50 | 74 | let current = /** @type {Element | null} */ (null); | |
| ⋯ 2 unchanged lines | |||
| 53 | 77 | window[PREV] = null; | |
| 54 | 78 | box.remove(); | |
| 55 | 79 | label.remove(); | |
| 80 | + | sink.remove(); | |
| 81 | + | // Removing the sink leaves the document itself focused; hand focus back | |
| 82 | + | // to whatever had it, so a pick doesn't cost the page its caret. | |
| 83 | + | if (wasFocused && wasFocused.isConnected && wasFocused instanceof HTMLElement) { | |
| 84 | + | wasFocused.focus({ preventScroll: true }); | |
| 85 | + | } | |
| 56 | 86 | document.removeEventListener("mousemove", onMove, true); | |
| 57 | 87 | document.removeEventListener("click", onClick, true); | |
| 58 | - | document.removeEventListener("keydown", onKey, true); | |
| 88 | + | window.removeEventListener("keydown", onKey, true); | |
| 59 | 89 | window.removeEventListener("scroll", onScroll, true); | |
| 60 | 90 | }; | |
| 61 | 91 | window[PREV] = () => { | |
| ⋯ 18 unchanged lines | |||
| 80 | 110 | ||
| 81 | 111 | /** @param {MouseEvent} e */ | |
| 82 | 112 | function onMove(e) { | |
| 113 | + | // A click inside a cross-origin iframe never reaches this document, but | |
| 114 | + | // it does move the focus there. Moving the mouse is the first thing that | |
| 115 | + | // tells us we are still up, so take the focus back then. | |
| 116 | + | grabFocus(); | |
| 83 | 117 | const hit = /** @type {Element | null} */ (e.target); | |
| 84 | 118 | const el = | |
| 85 | 119 | hit && hit !== box && hit !== label | |
| ⋯ 34 unchanged lines | |||
| 120 | 154 | ||
| 121 | 155 | document.addEventListener("mousemove", onMove, true); | |
| 122 | 156 | document.addEventListener("click", onClick, true); | |
| 123 | - | document.addEventListener("keydown", onKey, true); | |
| 157 | + | // On `window` rather than `document`: capture starts at the window, so this | |
| 158 | + | // runs before any handler the page installed, even a capturing one. | |
| 159 | + | window.addEventListener("keydown", onKey, true); | |
| 124 | 160 | window.addEventListener("scroll", onScroll, true); | |
| 125 | 161 | ||
| 126 | 162 | // --- identifier construction --------------------------------------------- | |
| ⋯ 132 unchanged lines | |||
modifiedextension/sidebar.css+154 −39
| ⋯ 60 unchanged lines | |||
| 61 | 61 | corner of its own rather than inside any one of them. | |
| 62 | 62 | ||
| 63 | 63 | The header's own background is the terminal's, not the panel's: every strip | |
| 64 | - | paints its own surface over it, and what is left uncovered is the corner — | |
| 65 | - | which should read as a continuation of the row it sits beside rather than as | |
| 66 | - | a slab of a fourth colour laid over the end of them. */ | |
| 64 | + | paints its own surface over it, edge to edge — including under the corner, | |
| 65 | + | which is laid over the end of the rows and paints nothing of its own. Beside | |
| 66 | + | each row it is that row: no fourth colour, and no seam. */ | |
| 67 | 67 | header { | |
| 68 | 68 | display: flex; align-items: stretch; | |
| 69 | 69 | background: var(--bg); flex: none; | |
| 70 | + | /* The corner is positioned against this, not laid out beside the rows — | |
| 71 | + | see #corner. */ | |
| 72 | + | position: relative; | |
| 73 | + | --corner-w: 56px; | |
| 70 | 74 | } | |
| 71 | 75 | #rows { | |
| 72 | 76 | display: flex; flex-direction: column; | |
| ⋯ 2 unchanged lines | |||
| 75 | 79 | /* A column reserved across every row, holding the one control that types into | |
| 76 | 80 | the pane rather than steering the tabs: hold-to-talk, and nothing else — the | |
| 77 | 81 | hold sends its own Return, so there is no second button to stack under it. | |
| 78 | - | Unpainted, so it takes the header's background and reads as an extension of | |
| 79 | - | the bottom row instead of a slab of its own — the rows are tmux and their | |
| 80 | - | surfaces are the nesting; a fourth colour beside them was one surface too | |
| 81 | - | many, and it was the one thing in the header with a hard vertical seam down | |
| 82 | - | it. | |
| 82 | + | Unpainted, and over the rows rather than beside them, so each row's own | |
| 83 | + | surface continues under it and the corner is that row's colour at every | |
| 84 | + | height — the rows are tmux and their surfaces are the nesting; a fourth | |
| 85 | + | colour beside them was one surface too many, and it was the one thing in the | |
| 86 | + | header with a hard vertical seam down it. | |
| 83 | 87 | ||
| 84 | 88 | Its width is its own — a square is what the mic wants, and the rows have no | |
| 85 | - | say in it — while its height is whatever the rows come to. Not the other way | |
| 86 | - | round: sizing the button off the rows' height and the corner off the button | |
| 87 | - | is a loop, and the browser breaks it by shrinking the strips. Which is why | |
| 88 | - | the mic below is sized *from* that height rather than fixed: the header has | |
| 89 | - | one row or two depending on the mode, and the corner is as tall as either. | |
| 89 | + | say in it — while its height is whatever the rows come to, since it is | |
| 90 | + | stretched between the header's top and bottom edges and the header is sized | |
| 91 | + | by the rows alone. Which is why the mic below is sized *from* that height | |
| 92 | + | rather than fixed: the header has one row or two depending on the mode, and | |
| 93 | + | the corner is as tall as either. Nothing here can push the header taller, so | |
| 94 | + | the mic's floor has to stay under the shortest the rows ever get. | |
| 90 | 95 | ||
| 91 | 96 | `gap` and the bottom padding are the mic's own air, not a gutter between | |
| 92 | 97 | stacked controls: it is centred in the corner and the padding keeps its ring | |
| 93 | 98 | off the terminal's edge while it is held. */ | |
| 94 | 99 | #corner { | |
| 95 | - | flex: none; width: 56px; | |
| 100 | + | flex: none; width: var(--corner-w); | |
| 96 | 101 | display: flex; flex-direction: column; | |
| 97 | 102 | align-items: center; justify-content: center; gap: 3px; | |
| 98 | 103 | padding: 3px 4px 4px; | |
| 99 | - | /* Not a flat fill: beside the top row the header's surface is the strips', | |
| 100 | - | and by the bottom it is the terminal's, so the corner falls from one to the | |
| 101 | - | other instead of meeting both with the same edge. The stops are soft on | |
| 102 | - | purpose — the exact row heights are not knowable here, and a gradient that | |
| 103 | - | tried to line up with them would be wrong in the mode that has one row. */ | |
| 104 | - | background: linear-gradient( | |
| 105 | - | to bottom, | |
| 106 | - | var(--bg-row), | |
| 107 | - | color-mix(in srgb, var(--bg-row) 35%, var(--bg)) 55%, | |
| 108 | - | var(--bg) 85% | |
| 109 | - | ); | |
| 104 | + | /* Nothing painted here at all, and out of the flow: the corner is laid over | |
| 105 | + | the right end of the rows, which run the full width behind it and reserve | |
| 106 | + | its column in their own right padding. Padding is inside a background box, | |
| 107 | + | so each row's surface carries on under the corner and stops exactly where | |
| 108 | + | that row does. | |
| 109 | + | ||
| 110 | + | That is the only way this corner meets both rows with the same edge. The | |
| 111 | + | rows are not one surface and their exact heights are not knowable in CSS, | |
| 112 | + | so a fill of its own — flat or graded — is a guess at where the boundary | |
| 113 | + | is, and a guess wrong by a few pixels is a step down the corner's left edge | |
| 114 | + | (in light, where the levels are 9 units apart rather than dark's 2, an | |
| 115 | + | obvious one). Letting the rows paint it means there is no boundary to | |
| 116 | + | guess: the top half is the top row because it *is* the top row, in either | |
| 117 | + | layout and at any density. */ | |
| 118 | + | position: absolute; top: 0; right: 0; bottom: 0; | |
| 119 | + | background: none; | |
| 110 | 120 | } | |
| 111 | 121 | /* Stretch, not centre: the active window tab has to reach the bottom edge to | |
| 112 | 122 | sit on the seam. Everything else in the row re-centres itself. */ | |
| ⋯ 3 unchanged lines | |||
| 116 | 126 | so a gap on top of that reads as a hole between the controls and the tabs. | |
| 117 | 127 | The buttons sit against the tab strip the way a browser's do. */ | |
| 118 | 128 | gap: 0; | |
| 119 | - | padding: 4px 6px 0 4px; | |
| 129 | + | /* The right padding is the row's own 6px plus the corner's column: the rows | |
| 130 | + | run under the corner so their surfaces reach the panel's edge, and nothing | |
| 131 | + | in them may lay out there. */ | |
| 132 | + | padding: 4px calc(var(--corner-w) + 6px) 0 4px; | |
| 120 | 133 | } | |
| 121 | 134 | /* The controls that aren't buttons still need their own air. */ | |
| 122 | 135 | .strip > #status-text { margin-left: 6px; } | |
| ⋯ 483 unchanged lines | |||
| 606 | 619 | .tab-slot.elsewhere > button.tab { color: var(--fg); } | |
| 607 | 620 | .tab-slot.elsewhere > button.tab .name { font-weight: 600; } | |
| 608 | 621 | ||
| 622 | + | /* The row the browser tab on screen is pinned to. | |
| 623 | + | ||
| 624 | + | A dot rather than a border or a tint: the tab silhouette is built out of | |
| 625 | + | masks and backgrounds (see the run above), and both of the obvious markings | |
| 626 | + | would have to be drawn through that. | |
| 627 | + | ||
| 628 | + | ::after, and absolutely positioned, for two separate reasons. ::before on | |
| 629 | + | these three is already the tab separator, and a flex item here would push the | |
| 630 | + | name along and collide with the ✕ that overlays the trailing edge. Riding in | |
| 631 | + | the leading padding instead costs no layout: every one of the three keeps at | |
| 632 | + | least 6px there (7px on a pinned tab and on a chip, before its caret), and | |
| 633 | + | the dot is 4px wide starting 1px in. Vertically centred, which is also the | |
| 634 | + | one height at which a chip's 9px pill edge is straight. | |
| 635 | + | ||
| 636 | + | Small and in the accent, because this is a "why did it move" answer read once | |
| 637 | + | and then ignored — the tooltip carries the sentence. */ | |
| 638 | + | .tab-slot.tab-linked > button.tab::after, | |
| 639 | + | button.session-tab.tab-linked::after, | |
| 640 | + | button.group-chip.tab-linked::after { | |
| 641 | + | content: ""; | |
| 642 | + | position: absolute; top: 50%; translate: 0 -50%; | |
| 643 | + | left: 1px; | |
| 644 | + | width: 4px; height: 4px; | |
| 645 | + | border-radius: 50%; | |
| 646 | + | background: var(--accent-hover); | |
| 647 | + | pointer-events: none; | |
| 648 | + | } | |
| 649 | + | ||
| 609 | 650 | /* --- the omnibar ----------------------------------------------------------- | |
| 610 | 651 | Groups mode's second row: a browser's address bar, under the tabs, in the | |
| 611 | 652 | same place and doing the same job — search what you have, or create what you | |
| 612 | - | named. The picker and the jump button lead it, in that order, and Enter and | |
| 613 | - | push-to-talk end it — the same pair, in the same order, that leads the window | |
| 614 | - | row in the nested layout. */ | |
| 653 | + | named. The picker leads it and jump follows the box, the way a browser puts | |
| 654 | + | what acts on the page before the bar and what takes you onward after it. */ | |
| 615 | 655 | #omni-strip { | |
| 616 | 656 | background: var(--bg); | |
| 617 | - | padding: 3px 6px 4px 4px; | |
| 657 | + | /* Less on the right than the left now that a button ends the row rather than | |
| 658 | + | the pill: an icon's box is already wider than its glyph, so the same 6px | |
| 659 | + | past it reads as a bigger gap than the pill's edge did. */ | |
| 660 | + | padding: 3px calc(var(--corner-w) + 2px) 4px 4px; | |
| 618 | 661 | align-items: center; | |
| 619 | 662 | gap: 2px; | |
| 620 | 663 | } | |
| ⋯ 1 unchanged line | |||
| 622 | 665 | border and the focus ring have to go around both. */ | |
| 623 | 666 | #omni-box { | |
| 624 | 667 | flex: 1 1 auto; min-width: 0; | |
| 625 | - | display: flex; align-items: center; gap: 6px; | |
| 668 | + | /* Aligned to the top rather than centred, because the box is only sometimes | |
| 669 | + | one line tall: once the text wraps, the info button and the directory stay | |
| 670 | + | on the first line — beside the start of what you typed — instead of | |
| 671 | + | drifting to the middle of a paragraph. At one line the two are the same | |
| 672 | + | thing, since every child is a line-box tall. */ | |
| 673 | + | display: flex; align-items: flex-start; gap: 6px; | |
| 626 | 674 | /* Nothing inside may paint past the pill: at the narrowest the directory is | |
| 627 | 675 | capped rather than shrunk, so it can want a few pixels more than are left. */ | |
| 628 | 676 | overflow: hidden; | |
| 629 | - | height: 24px; | |
| 630 | - | padding: 0 9px; | |
| 677 | + | /* Height comes from the text now: one line is the old 24px (18px of line box | |
| 678 | + | plus this padding) and every wrapped line adds 18 more, up to the cap on | |
| 679 | + | #omni itself. */ | |
| 680 | + | min-height: 24px; | |
| 681 | + | padding: 3px 9px; | |
| 631 | 682 | border: 1px solid var(--border); border-radius: 12px; | |
| 632 | 683 | background: var(--bg-input); | |
| 633 | 684 | } | |
| ⋯ 8 unchanged lines | |||
| 642 | 693 | beside it shrinks first, because a session you cannot read the name of is | |
| 643 | 694 | worse than a path missing its front. */ | |
| 644 | 695 | flex: 1 1 auto; min-width: 4rem; | |
| 645 | - | height: 100%; | |
| 646 | - | font: inherit; font-size: 11px; | |
| 696 | + | font: inherit; font-size: 11px; line-height: 18px; | |
| 647 | 697 | padding: 0; border: 0; background: none; color: var(--fg); | |
| 698 | + | /* A textarea's own affordances, all of them wrong here: it is a control in a | |
| 699 | + | chrome strip, not a form field, so it neither drags to resize nor keeps a | |
| 700 | + | scrollbar gutter it is not using. */ | |
| 701 | + | resize: none; | |
| 702 | + | /* One line to start with; JS sets the height to the content's on every | |
| 703 | + | change, and this caps how far that can go — past about six lines the box | |
| 704 | + | is eating the terminal, so it scrolls inside itself instead. */ | |
| 705 | + | height: 18px; max-height: 108px; | |
| 706 | + | overflow-y: auto; | |
| 707 | + | /* Wrap long words rather than let one run of characters — a path, a URL — | |
| 708 | + | decide the panel's minimum width. */ | |
| 709 | + | overflow-wrap: anywhere; | |
| 648 | 710 | } | |
| 649 | 711 | #omni:focus, #omni:focus-visible { outline: none; } | |
| 650 | 712 | #omni::placeholder { color: var(--fg-muted); } | |
| ⋯ 9 unchanged lines | |||
| 660 | 722 | an ellipsis. Fixed at its content width, the input absorbs the slack | |
| 661 | 723 | instead, and the only thing that can cut the path is the cap. */ | |
| 662 | 724 | flex: 0 0 auto; min-width: 0; max-width: 72%; | |
| 663 | - | font-size: 10px; | |
| 725 | + | /* The same line box as the text it sits beside, so it rides the first line | |
| 726 | + | of a wrapped query rather than a half-step off it. */ | |
| 727 | + | font-size: 10px; line-height: 18px; | |
| 664 | 728 | color: var(--fg-muted); | |
| 665 | 729 | overflow: hidden; text-overflow: ellipsis; white-space: nowrap; | |
| 666 | 730 | direction: rtl; text-align: right; | |
| ⋯ 88 unchanged lines | |||
| 755 | 819 | } | |
| 756 | 820 | .info-pop .info-copy:hover { color: var(--fg); border-color: var(--accent); } | |
| 757 | 821 | ||
| 758 | - | body.compact #omni-strip { padding: 2px 4px 3px 4px; } | |
| 822 | + | body.compact #omni-strip { padding: 2px calc(var(--corner-w) + 4px) 3px 4px; } | |
| 759 | 823 | body.compact #omni-box { height: 21px; } | |
| 760 | 824 | ||
| 761 | 825 | /* Over the terminal, not in the header: the header is a flex column that sizes | |
| ⋯ 325 unchanged lines | |||
| 1087 | 1151 | body.has-session > header #status-text { display: none; } | |
| 1088 | 1152 | ||
| 1089 | 1153 | /* Compact: lose the status text, keep the targets clickable. */ | |
| 1090 | - | body.compact .strip { padding: 2px 4px 0; } | |
| 1154 | + | body.compact .strip { padding: 2px calc(var(--corner-w) + 4px) 0 4px; } | |
| 1091 | 1155 | body.compact #status-text { display: none; } | |
| 1092 | 1156 | body.compact button.icon { width: 24px; height: 24px; } | |
| 1093 | 1157 | /* The mic is a touch target rather than a glyph, so density trims it a little | |
| 1094 | 1158 | and then stops: below the platforms' 44px floor it stops being one. */ | |
| 1095 | - | body.compact #corner { width: 50px; padding: 3px 3px 4px; } | |
| 1159 | + | /* On the header, not the corner: it is the rows that reserve this column, and | |
| 1160 | + | they read it from there. */ | |
| 1161 | + | body.compact header { --corner-w: 50px; } | |
| 1162 | + | body.compact #corner { padding: 3px 3px 4px; } | |
| 1096 | 1163 | body.compact #talk.big { min-height: 38px; max-height: 44px; } | |
| 1097 | 1164 | body.compact #talk.big svg { width: 19px; height: 19px; } | |
| 1098 | 1165 | body.compact .tab-slot { max-width: calc(8rem + 2 * var(--tab-foot)); } | |
| ⋯ 190 unchanged lines | |||
| 1289 | 1356 | #font-reset { padding: 4px 8px; font-size: 11px; } | |
| 1290 | 1357 | #theme-select, #density-select, #tabmode-select { width: auto; background: var(--bg-input); color: var(--fg); | |
| 1291 | 1358 | border: 1px solid var(--border); border-radius: 4px; padding: 5px 7px; font-size: 11px; } | |
| 1359 | + | ||
| 1360 | + | /* --- offline overlay -------------------------------------------------------- | |
| 1361 | + | The connection's state, said in the middle of the panel rather than in the | |
| 1362 | + | corner of the header. Fixed and full-bleed so the card lands in the optical | |
| 1363 | + | centre at any density, but the layer is click-through: the header underneath | |
| 1364 | + | it is still how you switch sessions, and a disconnect must not take that | |
| 1365 | + | away. z-index sits under the omnibar list (5) and the settings card (30), so | |
| 1366 | + | either of those covers it rather than fighting it. */ | |
| 1367 | + | #offline { | |
| 1368 | + | position: fixed; inset: 0; z-index: 4; | |
| 1369 | + | display: flex; align-items: center; justify-content: center; | |
| 1370 | + | padding: 12px; | |
| 1371 | + | pointer-events: none; | |
| 1372 | + | } | |
| 1373 | + | /* The one thing on the layer that takes the pointer. Opaque rather than a scrim | |
| 1374 | + | over the whole panel: dimming everything would dim the header too, and the | |
| 1375 | + | header is the half that still works. */ | |
| 1376 | + | .offline-card { | |
| 1377 | + | pointer-events: auto; | |
| 1378 | + | width: min(15rem, 100%); | |
| 1379 | + | padding: 14px 14px 12px; | |
| 1380 | + | text-align: center; | |
| 1381 | + | background: var(--bg-panel); color: var(--fg); | |
| 1382 | + | border: 1px solid var(--border); border-radius: 12px; | |
| 1383 | + | box-shadow: 0 10px 30px var(--shadow), 0 2px 6px var(--shadow); | |
| 1384 | + | } | |
| 1385 | + | #offline-icon { display: block; width: 26px; height: 26px; margin: 0 auto 6px; | |
| 1386 | + | color: var(--fg-muted); } | |
| 1387 | + | #offline-title { margin: 0; font-size: 12px; font-weight: 600; letter-spacing: .01em; } | |
| 1388 | + | #offline-msg { margin: 4px 0 0; font-size: 11px; line-height: 1.45; color: var(--fg-muted); } | |
| 1389 | + | #offline-msg:empty { display: none; } | |
| 1390 | + | #offline-msg code { font-size: 10px; } | |
| 1391 | + | .offline-actions { display: flex; gap: 6px; justify-content: center; margin-top: 10px; } | |
| 1392 | + | .offline-actions button { padding: 5px 12px; } | |
| 1393 | + | ||
| 1394 | + | /* While a connection is in flight there is nothing to press — pressing | |
| 1395 | + | Reconnect again would only replace the socket that is already dialling — so | |
| 1396 | + | the card swaps its buttons and its broken link for a ring that says "wait". */ | |
| 1397 | + | #offline-spinner { display: none; width: 22px; height: 22px; margin: 2px auto 8px; | |
| 1398 | + | border: 2px solid var(--border); border-top-color: var(--accent); | |
| 1399 | + | border-radius: 50%; animation: offline-spin 700ms linear infinite; } | |
| 1400 | + | #offline[data-state="pending"] #offline-spinner { display: block; } | |
| 1401 | + | #offline[data-state="pending"] #offline-icon, | |
| 1402 | + | #offline[data-state="pending"] .offline-actions { display: none; } | |
| 1403 | + | @keyframes offline-spin { to { transform: rotate(360deg); } } | |
| 1404 | + | @media (prefers-reduced-motion: reduce) { | |
| 1405 | + | #offline-spinner { animation-duration: 2s; } | |
| 1406 | + | } | |
modifiedextension/sidebar.html+83 −4
| ⋯ 160 unchanged lines | |||
| 161 | 161 | <circle cx="10" cy="10.5" r="1.9" fill="none" stroke="currentColor" stroke-width="1.5"/> | |
| 162 | 162 | </svg> | |
| 163 | 163 | </button> | |
| 164 | - | <!-- A plain text input. Not a combobox: it does not fill itself in and | |
| 164 | + | <!-- A plain text box. Not a combobox: it does not fill itself in and | |
| 165 | 165 | there is no value to commit — what it holds is a query, and the list | |
| 166 | 166 | under it is results, so Enter runs a result rather than accepting a | |
| 167 | - | completion. The list announces itself instead. --> | |
| 168 | - | <input id="omni" type="text" | |
| 167 | + | completion. The list announces itself instead. | |
| 168 | + | ||
| 169 | + | A textarea rather than an input, for one reason: what gets typed | |
| 170 | + | here is often a sentence — `!claude <a whole prompt>` — and an input | |
| 171 | + | answers a long one by scrolling its own text sideways, which hides | |
| 172 | + | the beginning of the thing you are still writing. This wraps and the | |
| 173 | + | pill grows down instead, up to a few lines. It is still one line of | |
| 174 | + | *value*: Enter runs the chosen row and newlines never get in (see | |
| 175 | + | the input handler), so the box behaves exactly as it reads. --> | |
| 176 | + | <textarea id="omni" rows="1" wrap="soft" | |
| 169 | 177 | aria-label="Jump to a window, name a new session, or run a command with !" | |
| 170 | 178 | placeholder="Jump, create or !run…" | |
| 171 | 179 | title="Jump to a window, session or Claude pane — type a name to create a session, or !command to run one in a new window" | |
| 172 | - | spellcheck="false" autocomplete="off" /> | |
| 180 | + | spellcheck="false" autocomplete="off"></textarea> | |
| 173 | 181 | <!-- The other half of what an address bar says: not just which session, | |
| 174 | 182 | but where it is. The name alone never tells you that, and it is the | |
| 175 | 183 | thing you want to know before typing a command into the box below. | |
| ⋯ 73 unchanged lines | |||
| 249 | 257 | ||
| 250 | 258 | <div id="term"></div> | |
| 251 | 259 | ||
| 260 | + | <!-- What a dead socket looks like. The word in the header is easy to miss — | |
| 261 | + | it sits in a row of controls, in the smallest type in the panel, while | |
| 262 | + | the scrollback below it still shows a session that is no longer there. | |
| 263 | + | So the state gets said over the terminal instead, where the eye already | |
| 264 | + | is, with the two things you would go looking for on it: retry, and the | |
| 265 | + | settings that hold the token and the URL. | |
| 266 | + | ||
| 267 | + | Over the terminal rather than in the flex column, for the reason the | |
| 268 | + | omnibar list is: a card in the column would resize the terminal, and | |
| 269 | + | xterm.js would reflow the scrollback you are still reading. The layer | |
| 270 | + | itself is click-through (`pointer-events: none`) so the header keeps | |
| 271 | + | working while it is up; only the card takes the pointer. --> | |
| 272 | + | <div id="offline" hidden> | |
| 273 | + | <div class="offline-card" role="status" aria-live="polite"> | |
| 274 | + | <!-- A broken link, which is what happened: the two halves are still | |
| 275 | + | there and no longer joined. Hidden while connecting — the spinner | |
| 276 | + | the card grows in its place says the same thing in motion. --> | |
| 277 | + | <svg id="offline-icon" viewBox="0 0 24 24" aria-hidden="true"> | |
| 278 | + | <path d="M9.5 7.5H7a4.5 4.5 0 0 0 0 9h2.5M14.5 7.5H17a4.5 4.5 0 0 1 0 9h-2.5" | |
| 279 | + | fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/> | |
| 280 | + | <path d="M9.6 12h1.2M13.2 12h1.2" stroke="currentColor" | |
| 281 | + | stroke-width="1.7" stroke-linecap="round"/> | |
| 282 | + | </svg> | |
| 283 | + | <div id="offline-spinner" aria-hidden="true"></div> | |
| 284 | + | <p id="offline-title">disconnected</p> | |
| 285 | + | <p id="offline-msg"></p> | |
| 286 | + | <div class="offline-actions"> | |
| 287 | + | <button id="offline-retry">Reconnect</button> | |
| 288 | + | <button id="offline-settings" class="ghost">Settings</button> | |
| 289 | + | </div> | |
| 290 | + | </div> | |
| 291 | + | </div> | |
| 292 | + | ||
| 252 | 293 | <!-- A card hung off the chevron rather than a drawer at the foot of the | |
| 253 | 294 | panel — the shape Chrome gives the popup on the other end of that same | |
| 254 | 295 | chevron. Positioned out of flow deliberately: as a flex item it sized | |
| ⋯ 48 unchanged lines | |||
| 303 | 344 | </div> | |
| 304 | 345 | <p class="hint" id="tabmode-hint"></p> | |
| 305 | 346 | ||
| 347 | + | <!-- What a plain click on "+" opens. Both kinds are always in the button's | |
| 348 | + | hold menu; this only says which one the click itself is. --> | |
| 349 | + | <div class="row" id="newtab-row"> | |
| 350 | + | <select id="newtab-select" title="What the "+" button opens"> | |
| 351 | + | <option value="claude">"+" opens claude</option> | |
| 352 | + | <option value="window">"+" opens a shell</option> | |
| 353 | + | </select> | |
| 354 | + | </div> | |
| 355 | + | <p class="hint" id="newtab-hint"></p> | |
| 356 | + | ||
| 357 | + | <!-- Following the browser. Right-click a session or a window to pin it to | |
| 358 | + | the tab you are on; these three only say whether pins are acted on at | |
| 359 | + | all, whether an unpinned localhost port may guess at one, and whether | |
| 360 | + | the same pins also read backwards. --> | |
| 361 | + | <div class="row"> | |
| 362 | + | <label class="row" | |
| 363 | + | ><input id="follow-tabs" type="checkbox" /> follow browser tabs</label | |
| 364 | + | > | |
| 365 | + | </div> | |
| 366 | + | <div class="row"> | |
| 367 | + | <label class="row" | |
| 368 | + | ><input id="detect-devport" type="checkbox" /> guess by devport port</label | |
| 369 | + | > | |
| 370 | + | </div> | |
| 371 | + | <div class="row"> | |
| 372 | + | <label class="row" | |
| 373 | + | ><input id="lead-tabs" type="checkbox" /> and switch tabs back</label | |
| 374 | + | > | |
| 375 | + | </div> | |
| 376 | + | <p class="hint"> | |
| 377 | + | A pinned browser tab brings its session up when you switch to it, and | |
| 378 | + | switching to the session brings the tab back up. Right-click a session | |
| 379 | + | or window tab to pin one. | |
| 380 | + | </p> | |
| 381 | + | ||
| 306 | 382 | <div class="row" id="font-row"> | |
| 307 | 383 | <span class="hint">Font size</span> | |
| 308 | 384 | <button id="font-smaller" class="icon" title="Smaller (Ctrl+Alt+- or Ctrl+wheel)"> | |
| ⋯ 38 unchanged lines | |||
| 347 | 423 | <script src="lib/sanitize.js"></script> | |
| 348 | 424 | <script src="lib/shot.js"></script> | |
| 349 | 425 | <script src="lib/split.js"></script> | |
| 426 | + | <script src="lib/devport.js"></script> | |
| 427 | + | <script src="lib/tabpin.js"></script> | |
| 350 | 428 | <script src="picker.js"></script> | |
| 351 | 429 | <script src="vendor/xterm.js"></script> | |
| 352 | 430 | <script src="vendor/addon-fit.js"></script> | |
| 431 | + | <script src="vendor/addon-web-links.js"></script> | |
| 353 | 432 | <script src="sidebar.js"></script> | |
| 354 | 433 | </body> | |
| 355 | 434 | </html> | |
modifiedextension/sidebar.js+8 −0
| ⋯ 276 unchanged lines | |||
| 277 | 277 | ||
| 278 | 278 | const fit = new FitAddon.FitAddon(); | |
| 279 | 279 | term.loadAddon(fit); | |
| 280 | + | // Default handler is `window.open`, which the side panel can't reliably pop | |
| 281 | + | // as a real browser tab; route through the extension tabs API instead. | |
| 282 | + | term.loadAddon( | |
| 283 | + | new WebLinksAddon.WebLinksAddon((event, uri) => { | |
| 284 | + | event.preventDefault(); | |
| 285 | + | api.tabs.create({ url: uri }); | |
| 286 | + | }), | |
| 287 | + | ); | |
| 280 | 288 | term.open($("term")); | |
| 281 | 289 | fit.fit(); | |
| 282 | 290 | ||
| ⋯ 5360 unchanged lines | |||
modifiedextension/sw.js+14 −0
| ⋯ 25 unchanged lines | |||
| 26 | 26 | ||
| 27 | 27 | const SKIP = /^(chrome|about|edge|moz-extension|chrome-extension|view-source|devtools):/; | |
| 28 | 28 | ||
| 29 | + | // The tabs a shortcut-started pick is running in. Empty means none is. | |
| 30 | + | let pickTabs = /** @type {number[]} */ ([]); | |
| 31 | + | ||
| 29 | 32 | async function runPicker() { | |
| 30 | 33 | const [tab] = await api.tabs.query({ active: true, currentWindow: true }); | |
| 31 | 34 | if (!tab || SKIP.test(tab.url ?? "")) { | |
| ⋯ 6 unchanged lines | |||
| 38 | 41 | // reachable only where its origin was granted — Split.racePick treats that | |
| 39 | 42 | // refusal as one runner dropping out, not as the pick failing. | |
| 40 | 43 | const targets = (await Split.pickTargets(api, tab)).filter((t) => !SKIP.test(t.url ?? "")); | |
| 44 | + | // Remembered so the panel can call the pick off: this path's Escape has to | |
| 45 | + | // work from the panel too, and the panel doesn't know this pick exists. | |
| 46 | + | pickTabs = []; | |
| 47 | + | for (const t of targets) if (t.id != null) pickTabs.push(t.id); | |
| 41 | 48 | const { tabId, value, error } = await Split.racePick(api, targets, tbPickElement); | |
| 49 | + | pickTabs = []; | |
| 42 | 50 | if (error) console.error("termbridge: pick failed", error); | |
| 43 | 51 | if (!value) return; | |
| 44 | 52 | ||
| ⋯ 48 unchanged lines | |||
| 93 | 101 | if (asked != null && Date.now() - asked < PENDING_OMNIBAR_MS) { | |
| 94 | 102 | port.postMessage({ type: "omnibar" }); | |
| 95 | 103 | } | |
| 104 | + | } else if (msg?.type === "cancel-pick") { | |
| 105 | + | // Escape in the panel, for a pick the shortcut started here. Harmless | |
| 106 | + | // when nothing is picking: the list is empty and this does nothing. | |
| 107 | + | const ids = pickTabs; | |
| 108 | + | pickTabs = []; | |
| 109 | + | if (ids.length) Split.cancelPicks(api, ids); | |
| 96 | 110 | } else if (msg?.type === "focus" && windowId != null) { | |
| 97 | 111 | const entry = panels.get(windowId); | |
| 98 | 112 | if (entry?.port === port) entry.focused = !!msg.focused; | |
| ⋯ 224 unchanged lines | |||
modifiedextension/types/globals.d.ts+85 −0
| ⋯ 14 unchanged lines | |||
| 15 | 15 | windowId?: number; | |
| 16 | 16 | url?: string; | |
| 17 | 17 | title?: string; | |
| 18 | + | /** Whether this is the showing tab of its window. Only the reverse pin reads | |
| 19 | + | it, to notice that the browser is already where it would have moved it. */ | |
| 20 | + | active?: boolean; | |
| 21 | + | /** When the tab was last showing, as an epoch millisecond count. Chrome 121+ | |
| 22 | + | and Firefox both set it; absent elsewhere, which only costs the reverse | |
| 23 | + | pin its tie-break between two tabs that point at the same session. */ | |
| 24 | + | lastAccessed?: number; | |
| 18 | 25 | /** The split view this tab is half of, if any — Chrome 140+, and read-only: | |
| 19 | 26 | the API detects splits, it cannot create or dissolve them. Absent in | |
| 20 | 27 | Firefox, which has no split view at all. */ | |
| ⋯ 75 unchanged lines | |||
| 96 | 103 | query(info: { | |
| 97 | 104 | active?: boolean; | |
| 98 | 105 | currentWindow?: boolean; | |
| 106 | + | /** Both set by the tab-pin code, which cares about one browser window — | |
| 107 | + | its own — and about which tab in it is showing. */ | |
| 108 | + | windowId?: number; | |
| 99 | 109 | /** Chrome throws on this key rather than ignoring it where it isn't | |
| 100 | 110 | supported, so every call that passes it is wrapped. */ | |
| 101 | 111 | splitViewId?: number; | |
| 102 | 112 | }): Promise<TbTab[]>; | |
| 113 | + | /** One tab by id, for the pin code: a tab activation carries an id and | |
| 114 | + | nothing else, and the URL is what a pin is keyed on. */ | |
| 115 | + | get(tabId: number): Promise<TbTab>; | |
| 103 | 116 | create(props: { url: string }): Promise<TbTab>; | |
| 117 | + | /** A tab became the showing one in its window. Fires for every window, so | |
| 118 | + | the sidebar filters on its own. */ | |
| 119 | + | onActivated: { | |
| 120 | + | addListener(cb: (info: { tabId: number; windowId: number }) => void): void; | |
| 121 | + | }; | |
| 122 | + | /** Navigation inside a tab that is already open — the same page becoming a | |
| 123 | + | different origin, which is a different pin. `url` is only present when | |
| 124 | + | it changed, and only with the "tabs" permission. */ | |
| 125 | + | onUpdated: { | |
| 126 | + | addListener( | |
| 127 | + | cb: (tabId: number, change: { url?: string; status?: string }, tab: TbTab) => void, | |
| 128 | + | ): void; | |
| 129 | + | }; | |
| 130 | + | /** Tab ids are per-run and never reissued, so a tab pin outlives its tab | |
| 131 | + | unless something drops it. This is that something. */ | |
| 132 | + | onRemoved: { | |
| 133 | + | addListener(cb: (tabId: number) => void): void; | |
| 134 | + | }; | |
| 104 | 135 | /** Only used to make a tab active, which needs no permission. captureVisibleTab | |
| 105 | 136 | has no tabId of its own, so a split-view pick has to be activated first. */ | |
| 106 | 137 | update(tabId: number, props: { active: boolean }): Promise<TbTab>; | |
| ⋯ 112 unchanged lines | |||
| 219 | 250 | activity: boolean; | |
| 220 | 251 | } | |
| 221 | 252 | ||
| 253 | + | // --- pinning a session to a browser tab ------------------------------------- | |
| 254 | + | // | |
| 255 | + | // Panel state, not server state: none of this reaches the socket, and the | |
| 256 | + | // daemon has never heard of a browser tab. See lib/tabpin.js. | |
| 257 | + | ||
| 258 | + | /** Where a pinned browser tab sends the terminal. A null window means "the | |
| 259 | + | session, wherever it is currently pointed". */ | |
| 260 | + | interface TbPinTarget { | |
| 261 | + | session: string; | |
| 262 | + | window?: string | null; | |
| 263 | + | } | |
| 264 | + | ||
| 265 | + | /** Which rule answered — shown in the tooltip, because a switch nobody | |
| 266 | + | remembers asking for should say where it came from. */ | |
| 267 | + | type TbPinSource = "tab" | "origin" | "detect"; | |
| 268 | + | ||
| 269 | + | /** A place in tmux. A null window means the session's own current window, | |
| 270 | + | which is what a session-level pin points at. */ | |
| 271 | + | interface TbSpot { | |
| 272 | + | session: string; | |
| 273 | + | window?: string | null; | |
| 274 | + | } | |
| 275 | + | ||
| 276 | + | /** An excursion in progress: a pin moved the terminal `to` somewhere, and owes | |
| 277 | + | it back to `from` when the browser leaves for a tab with no pin. */ | |
| 278 | + | interface TbPinReturn { | |
| 279 | + | from: TbSpot; | |
| 280 | + | to: TbSpot; | |
| 281 | + | } | |
| 282 | + | ||
| 283 | + | interface TbTabPinStore { | |
| 284 | + | /** The whole feature, off. Pins are kept while it is off. */ | |
| 285 | + | enabled: boolean; | |
| 286 | + | /** The devport guess for localhost ports. Explicit pins work regardless. */ | |
| 287 | + | detect: boolean; | |
| 288 | + | /** The other direction: moving the terminal activates the pinned tab. Reads | |
| 289 | + | the same pins as the forward direction, and is separately switchable | |
| 290 | + | because it is the half that touches the browser. */ | |
| 291 | + | reverse: boolean; | |
| 292 | + | /** Keyed by browser tab id, as a string — storage round-trips JSON, and JSON | |
| 293 | + | object keys are strings. A null value is a veto. */ | |
| 294 | + | byTab: Record<string, TbPinTarget | null>; | |
| 295 | + | /** Keyed by origin, e.g. `http://localhost:26210`. */ | |
| 296 | + | byOrigin: Record<string, TbPinTarget | null>; | |
| 297 | + | } | |
| 298 | + | ||
| 222 | 299 | /** | |
| 223 | 300 | * What an agent is doing. The header's glyph slot takes this plus `"none"` — its | |
| 224 | 301 | * own name for "no agent here", which it needs because every row has the slot | |
| ⋯ 173 unchanged lines | |||
| 398 | 475 | } | |
| 399 | 476 | } | |
| 400 | 477 | ||
| 478 | + | declare namespace WebLinksAddon { | |
| 479 | + | class WebLinksAddon { | |
| 480 | + | constructor(handler?: (event: MouseEvent, uri: string) => void); | |
| 481 | + | } | |
| 482 | + | } | |
| 483 | + | ||
| 401 | 484 | // --- our own files, as the browser sees them -------------------------------- | |
| 402 | 485 | // | |
| 403 | 486 | // lib/theme.js and lib/sanitize.js are loaded as classic scripts here and | |
| ⋯ 7 unchanged lines | |||
| 411 | 494 | declare const Sanitize: typeof import("../lib/sanitize.js"); | |
| 412 | 495 | declare const Shot: typeof import("../lib/shot.js"); | |
| 413 | 496 | declare const Split: typeof import("../lib/split.js"); | |
| 497 | + | declare const Devport: typeof import("../lib/devport.js"); | |
| 498 | + | declare const Tabpin: typeof import("../lib/tabpin.js"); | |
| 414 | 499 | ||
| 415 | 500 | /** Set by picker.js inside the *page*, not here — see cancelPick(). */ | |
| 416 | 501 | interface Window { | |
| ⋯ 2 unchanged lines | |||
addedextension/vendor/addon-web-links.js+2 −0
| 1 | + | !function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.WebLinksAddon=t():e.WebLinksAddon=t()}(self,(()=>(()=>{"use strict";var e={6:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.LinkComputer=t.WebLinkProvider=void 0,t.WebLinkProvider=class{constructor(e,t,n,i={}){this._terminal=e,this._regex=t,this._handler=n,this._options=i}provideLinks(e,t){const i=n.computeLink(e,this._regex,this._terminal,this._handler);t(this._addCallbacks(i))}_addCallbacks(e){return e.map((e=>(e.leave=this._options.leave,e.hover=(t,n)=>{if(this._options.hover){const{range:i}=e;this._options.hover(t,n,i)}},e)))}};class n{static computeLink(e,t,i,r){const o=new RegExp(t.source,(t.flags||"")+"g"),[s,a]=n._getWindowedLineStrings(e-1,i),c=s.join("");let d;const l=[];for(;d=o.exec(c);){const e=d[0];try{const t=new URL(e),n=decodeURI(t.toString());if(e!==n&&e+"/"!==n)continue}catch(e){continue}const[t,o]=n._mapStrIdx(i,a,0,d.index),[s,c]=n._mapStrIdx(i,t,o,e.length);if(-1===t||-1===o||-1===s||-1===c)continue;const p={start:{x:o+1,y:t+1},end:{x:c,y:s+1}};l.push({range:p,text:e,activate:r})}return l}static _getWindowedLineStrings(e,t){let n,i=e,r=e,o=0,s="";const a=[];if(n=t.buffer.active.getLine(e)){const e=n.translateToString(!0);if(n.isWrapped&&" "!==e[0]){for(o=0;(n=t.buffer.active.getLine(--i))&&o<2048&&(s=n.translateToString(!0),o+=s.length,a.push(s),n.isWrapped&&-1===s.indexOf(" ")););a.reverse()}for(a.push(e),o=0;(n=t.buffer.active.getLine(++r))&&n.isWrapped&&o<2048&&(s=n.translateToString(!0),o+=s.length,a.push(s),-1===s.indexOf(" ")););}return[a,i]}static _mapStrIdx(e,t,n,i){const r=e.buffer.active,o=r.getNullCell();let s=n;for(;i;){const e=r.getLine(t);if(!e)return[-1,-1];for(let n=s;n<e.length;++n){e.getCell(n,o);const s=o.getChars();if(o.getWidth()&&(i-=s.length||1,n===e.length-1&&""===s)){const e=r.getLine(t+1);e&&e.isWrapped&&(e.getCell(0,o),2===o.getWidth()&&(i+=1))}if(i<0)return[t,n]}t++,s=0}return[t,s]}}t.LinkComputer=n}},t={};function n(i){var r=t[i];if(void 0!==r)return r.exports;var o=t[i]={exports:{}};return e[i](o,o.exports,n),o.exports}var i={};return(()=>{var e=i;Object.defineProperty(e,"__esModule",{value:!0}),e.WebLinksAddon=void 0;const t=n(6),r=/https?:[/]{2}[^\s"'!*(){}|\\\^<>`]*[^\s"':,.!?{}|\\\^~\[\]`()<>]/;function o(e,t){const n=window.open();if(n){try{n.opener=null}catch(e){}n.location.href=t}else console.warn("Opening link blocked as opener could not be cleared")}e.WebLinksAddon=class{constructor(e=o,t={}){this._handler=e,this._options=t}activate(e){this._terminal=e;const n=this._options,i=n.urlRegex||r;this._linkProvider=this._terminal.registerLinkProvider(new t.WebLinkProvider(this._terminal,i,this._handler,n))}dispose(){var e;null===(e=this._linkProvider)||void 0===e||e.dispose()}}})(),i})())); | |
| 2 | + | //# sourceMappingURL=xterm-addon-web-links.js.map |