diff --git a/bun.lock b/bun.lock index 0c999c2ac..7157bd97a 100644 --- a/bun.lock +++ b/bun.lock @@ -10,7 +10,7 @@ "arktype": "^2.2.0", "better-sqlite3": "^12.9.0", "drizzle-orm": "^0.45.2", - "electron-chrome-extensions": "npm:@iamevan/electron-chrome-extensions@4.9.4", + "electron-chrome-extensions": "npm:@iamevan/electron-chrome-extensions@4.9.5", "electron-chrome-web-store": "npm:@iamevan/electron-chrome-web-store@0.13.3", "electron-context-menu": "^4.1.2", "electron-updater": "^6.8.3", @@ -1016,7 +1016,7 @@ "electron-builder-squirrel-windows": ["electron-builder-squirrel-windows@26.8.1", "", { "dependencies": { "app-builder-lib": "26.8.1", "builder-util": "26.8.1", "electron-winstaller": "5.4.0" } }, "sha512-o288fIdgPLHA76eDrFADHPoo7VyGkDCYbLV1GzndaMSAVBoZrGvM9m2IehdcVMzdAZJ2eV9bgyissQXHv5tGzA=="], - "electron-chrome-extensions": ["@iamevan/electron-chrome-extensions@4.9.4", "", { "dependencies": { "debug": "^4.3.1" } }, "sha512-JPbNppVGFOctuSFzs6DaKs8CTP67e8ifjhEaJOTNFKGDhSXr3uC1sF6IkQGm47O7HBsockhC11Hr5wGTciMqbA=="], + "electron-chrome-extensions": ["@iamevan/electron-chrome-extensions@4.9.5", "", { "dependencies": { "debug": "^4.3.1" } }, "sha512-ZBgI2xlFFhJlhd0O5ZF3FUpEfE7DIHewMbDnnZAI/A6f+DuCD2qqgKxbByFxDcCovVYjAJoGYNIPMUbBBuos6A=="], "electron-chrome-web-store": ["@iamevan/electron-chrome-web-store@0.13.3", "", { "dependencies": { "@types/chrome": "^0.0.287", "adm-zip": "^0.5.16", "debug": "^4.3.7", "pbf": "^4.0.1" } }, "sha512-DactkR+smswJWHsfUqSgujOP72tyyugfmYDapNl2Q+xkKvpXEwtPYMEY+vXCK4AHWQ85t0J6Us9EIkkLZuIi6Q=="], diff --git a/docs/tab-layout-refactor-plan.md b/docs/tab-layout-refactor-plan.md new file mode 100644 index 000000000..ef0f13367 --- /dev/null +++ b/docs/tab-layout-refactor-plan.md @@ -0,0 +1,394 @@ +# TabLayout Refactor Plan + +## Current Architecture + +``` +Window A +└── TabLayout (1 per window) + ├── activeNodeMap: Map + ├── focusedTabMap: Map + ├── activationHistory: Map + └── layoutNodes: Map ← ALL nodes in the window +``` + +**Problems:** + +1. `TabLayout` mixes concerns for multiple spaces using `WindowSpaceKey` composite keys +2. Bounds calculation lives in `TabService.handlePageBoundsChanged` — iterates all tabs in window, checks visibility, then asks the active node for sub-bounds +3. Space switching hides/shows tabs by iterating `getTabsInWindowSpace` — not layout-aware +4. In STAW mode, tabs physically move between windows (expensive screenshot + `setWindow` cycle) +5. Pinned tab nodes only exist in the window where they were created + +--- + +## Proposed Architecture + +``` +Window A +├── TabLayout (Space A) ← visible=true (window's current space) +│ ├── activeNode: TabLayoutNode +│ ├── focusedTab: Tab +│ ├── activationHistory: string[] +│ └── nodes: Set (all nodes in this layout) +│ +├── TabLayout (Space B) ← visible=false +│ ├── activeNode: TabLayoutNode +│ ├── ... +│ └── nodes: Set +│ +└── TabLayout (Space C) ← visible=false + └── ... +``` + +### Key: `TabLayoutNode` can appear in multiple `TabLayout`s + +``` +TabLayoutNode "ln-123" (for a STAW tab) +├── activeLayout: TabLayout (Space A, Window A) ← real content shown here +├── memberLayouts: Set ← {Window A/Space A, Window B/Space A} +└── In Window B/Space A: shows placeholder thumbnail +``` + +--- + +## Data Model Changes + +### `TabLayout` (one per window-space) + +```typescript +class TabLayout extends TypedEventEmitter { + readonly windowId: number; + readonly spaceId: string; + visible: boolean; // toggled on space switch + + // Per-layout state (no more composite keys) + private activeNode: TabLayoutNode | null; + private focusedTab: Tab | null; + private activationHistory: string[]; + + // Nodes belonging to this layout + private nodes: Map; + + // Main bounds (computed from window.pageBounds) + private mainBounds: Electron.Rectangle; + + // Methods + computeMainBounds(): Electron.Rectangle; // reads from window.pageBounds + setVisible(visible: boolean): void; + getActiveNode(): TabLayoutNode | null; + setActiveNode(node: TabLayoutNode): void; + ... +} +``` + +### `TabLayoutNode` changes + +```typescript +class TabLayoutNode extends TypedEventEmitter { + // Existing fields... + + // NEW: Which layouts this node belongs to + private _memberLayouts: Set = new Set(); + + // NEW: Which layout is "active" for this node (shows real content) + // Other member layouts show a placeholder thumbnail + private _activeLayout: TabLayout | null = null; + + // Methods + addToLayout(layout: TabLayout): void; + removeFromLayout(layout: TabLayout): void; + setActiveLayout(layout: TabLayout): void; + get activeLayout(): TabLayout | null; + get memberLayouts(): ReadonlySet; + + // Secondary bounds calculation + // Takes main bounds from the layout and returns actual bounds for each tab + computeBounds(mainBounds: Electron.Rectangle): Map; +} +``` + +### `TabService.layouts` changes + +```typescript +// OLD: Map +// NEW: Map where layoutKey = `${windowId}-${spaceId}` + +// Helper to get all layouts for a window +getLayoutsForWindow(windowId: number): TabLayout[]; + +// Helper to get the visible layout for a window +getVisibleLayout(windowId: number): TabLayout | null; + +// Helper to get layout for a specific window-space +getLayout(windowId: number, spaceId: string): TabLayout | undefined; + +// Create layout when space first has tabs in a window +getOrCreateLayout(windowId: number, spaceId: string): TabLayout; +``` + +--- + +## Bounds Calculation Split + +### Current (in `TabService.handlePageBoundsChanged`): + +``` +1. Get pageBounds from window +2. For each visible tab in window: + - If fullscreen → use full content size + - Else if in multi-tab node → computeNodeTabBounds() + - Else → use pageBounds directly +3. tab.view.setBounds(bounds) +``` + +### New: + +**TabLayout.computeMainBounds():** + +``` +1. Get pageBounds from window +2. If active node's front tab is fullscreen → full content size +3. Otherwise → pageBounds (or could factor in sidebar, other chrome) +4. Return mainBounds +``` + +**TabLayoutNode.computeBounds(mainBounds):** + +``` +For "single" mode: + → Return { tab: mainBounds } (passthrough) + +For "split" mode: + → Divide mainBounds horizontally by tab count + +For "glance" mode: + → Front tab: 85% centered, Back tab: 95% centered +``` + +**TabLayout.applyBounds():** + +``` +1. mainBounds = this.computeMainBounds() +2. If activeNode: + boundsMap = activeNode.computeBounds(mainBounds) + for each [tab, bounds] in boundsMap: + tab.view.setBounds(bounds) + tab.view.setBorderRadius(...) +``` + +--- + +## Space Switching + +### Current flow (`setCurrentWindowSpace`): + +1. Hide tabs in old space (iterate `getTabsInWindowSpace`) +2. Maybe activate a tab in new space +3. `updateTabVisibility` + `handlePageBoundsChanged` + +### New flow: + +1. `oldLayout.setVisible(false)` — hides all tabs in old layout +2. `newLayout.setVisible(true)` — shows tabs in new layout +3. `newLayout.applyBounds()` — position tabs correctly + +This is cleaner because: + +- Each `TabLayout` knows exactly which tabs it owns +- Visibility is a layout-level concept, not per-tab iteration +- Bounds calculation is self-contained + +--- + +## STAW Mode (Sync Tabs Across Windows) + +### Current: + +- When Window B focuses, the tab's view is physically moved from Window A → Window B +- A screenshot placeholder is left in Window A +- `moveTabToWindowIfNeeded` → `setWindow` → `migrateTabBetweenLayouts` + +### New: + +- The `TabLayoutNode` exists in BOTH `TabLayout`s (Window A/Space X AND Window B/Space X) +- The node's `_activeLayout` tracks which layout shows real content +- When Window B focuses: `node.setActiveLayout(layoutB)` — no physical move needed +- Non-active layouts show the placeholder thumbnail automatically +- The Tab's `view` is attached to the `_activeLayout`'s window + +**Benefits:** + +- No expensive screenshot + move cycle on every window focus +- Node state (position, activation history) is preserved in both layouts +- Switching back is instant (just change `_activeLayout`) + +**Migration of physical view:** +When `activeLayout` changes, the Tab's view needs to be reparented to the new window. +This is still needed but happens via `TabLayoutNode.setActiveLayout()`: + +```typescript +setActiveLayout(layout: TabLayout): void { + if (this._activeLayout === layout) return; + const oldLayout = this._activeLayout; + this._activeLayout = layout; + + // Move the view to the new window + for (const tab of this._tabs) { + if (tab.view && layout.windowId !== tab.getWindow().id) { + tab.setWindow(browserWindowsController.getWindowById(layout.windowId)); + } + } + + // Show placeholder in old layout + oldLayout?.showPlaceholderForNode(this); + // Show real content in new layout + layout.showContentForNode(this); + + this.emit("active-layout-changed", layout, oldLayout); +} +``` + +--- + +## Pinned Tab Nodes + +### Current: + +- Pinned tab has one live `Tab` at a time +- Tab moves between spaces (via `clickPinnedTab`) +- Node only exists in one layout + +### New: + +- When a pinned tab is activated in a space, its `TabLayoutNode` is registered in ALL `TabLayout`s for that profile's spaces in that window +- This way, switching spaces doesn't need special pinned-tab logic — the node is already there +- The node's `activeLayout` determines where the real view shows +- Other layouts show a placeholder (pinned tab icon/thumbnail) + +**Implementation:** + +```typescript +// When a pinned tab's node is created: +registerPinnedTabNode(node: TabLayoutNode, profileId: string): void { + // Add to all layouts whose space belongs to this profile + for (const [key, layout] of this.layouts) { + const space = spacesController.getFromCache(layout.spaceId); + if (space?.profileId === profileId) { + node.addToLayout(layout); + } + } +} +``` + +When a new space is created for that profile, it auto-adds existing pinned tab nodes. + +--- + +## Tab Visibility + +### Current: + +`updateTabVisibility(windowId, spaceId)` iterates all tabs in window+space, shows only active node's tabs. + +### New: + +Each `TabLayout` manages its own visibility: + +```typescript +class TabLayout { + setVisible(visible: boolean): void { + this.visible = visible; + if (visible) { + // Show active node's tabs + if (this.activeNode) { + // Only show if this is the node's active layout + if (this.activeNode.activeLayout === this) { + for (const tab of this.activeNode.tabs) { + tab.visible = true; + tab.layer?.setVisible(true); + } + } else { + // Show placeholder + this.showPlaceholderForNode(this.activeNode); + } + } + } else { + // Hide all tabs managed by this layout + for (const node of this.nodes.values()) { + if (node.activeLayout === this) { + for (const tab of node.tabs) { + tab.visible = false; + tab.layer?.setVisible(false); + } + } + } + } + } +} +``` + +--- + +## Migration Path + +### Phase 1: Change `TabLayout` to per-window-space + +1. Remove `WindowSpaceKey` composite — each layout has a single `spaceId` +2. Change `TabService.layouts` from `Map` to `Map` (keyed by `${windowId}-${spaceId}`) +3. Add `getLayoutsForWindow(windowId)` helper +4. Update all 23 callsites in `tab-service.ts` that access `this.layouts.get(windowId)` +5. Update 2 callsites in `tab-sync.ts` and 2 in `tab-ipc.ts` + +### Phase 2: Move bounds calculation into TabLayout/TabLayoutNode + +1. Add `computeMainBounds()` to `TabLayout` +2. Add `computeBounds(mainBounds)` to `TabLayoutNode` +3. Add `applyBounds()` to `TabLayout` +4. Remove `handlePageBoundsChanged` from `TabService` — call `layout.applyBounds()` instead + +### Phase 3: Implement STAW via multi-layout membership + +1. Add `_memberLayouts` and `_activeLayout` to `TabLayoutNode` +2. Update `registerNode` / node creation to register in relevant layouts +3. Replace physical tab-move-between-windows with `setActiveLayout` +4. Update placeholder logic to be layout-aware (not window-level `Map`) + +### Phase 4: Pinned tab nodes in all profile layouts + +1. When pinned tab node is created, register it in all layouts for that profile +2. Listen for new layouts (spaces) being created to auto-add pinned nodes +3. Remove the per-click space-move logic — node already exists everywhere + +### Phase 5: Visibility & space switching + +1. Implement `TabLayout.setVisible()` +2. Simplify `setCurrentWindowSpace` to just toggle layout visibility +3. Remove `updateTabVisibility` method (replaced by layout-level visibility) + +--- + +## Files to Modify + +| File | Changes | +| ------------------------- | ------------------------------------------------------------------------------------------------------- | +| `layout/tab-layout.ts` | Major rewrite: per-space, visibility, bounds, no composite keys | +| `core/tab-layout-node.ts` | Add `_memberLayouts`, `_activeLayout`, `computeBounds()` | +| `tab-service.ts` | Change `layouts` map, update all 23 callsites, remove `handlePageBoundsChanged` / `updateTabVisibility` | +| `tab-sync.ts` | Replace physical moves with `setActiveLayout`, simplify placeholders | +| `ipc/tab-ipc.ts` | Update layout queries | +| `persistence/` | Layout persistence now keyed by window+space | +| `saving/tabs/restore.ts` | Create layouts per space during restore | + +--- + +## Risks / Open Questions + +1. **Performance of multi-layout membership**: If a user has 20 spaces, does a pinned tab node being in 20 layouts cause overhead? → Likely fine, Sets are O(1) for add/remove/has. + +2. **View reparenting**: Even with the new model, moving a tab's `WebContentsView` to a different window's `contentView` is still needed. The benefit is that we can defer it (show placeholder immediately, move view async). + +3. **Activation history per layout**: Each layout has its own history — is this correct? When you switch spaces, should the history from the old space persist? → Yes, each layout's history is independent. + +4. **PiP transitions**: Currently triggered in `updateTabVisibility`. In the new model, they'd trigger in `TabLayout.setVisible(false)`. Need to preserve auto-PiP behavior. + +5. **IPC payload**: The renderer currently receives ALL tabs for the window. With per-space layouts, should we only send the current layout's tabs? → Probably still send all (sidebar shows all spaces' tabs). But layout nodes now come from the current layout only. diff --git a/docs/tab-service-architecture.md b/docs/tab-service-architecture.md new file mode 100644 index 000000000..2c3f9d947 --- /dev/null +++ b/docs/tab-service-architecture.md @@ -0,0 +1,256 @@ +# Tab Service v2 — Architecture Document + +## Overview + +The Tab Service is the central tab management system for Flow Browser. It replaces the legacy `tabs-controller` and `pinned-tabs-controller` with a modular OOP architecture designed for extensibility. + +**Total size:** ~5,400 lines across 17 files (vs. ~6,800 lines in the old system across 18 files). + +--- + +## Module Structure + +``` +src/main/services/tab-service/ +├── index.ts (57 lines) — Entry point, singleton exports, initialization +├── tab-service.ts (1461 lines) — Central orchestrator +├── tab-sync.ts (495 lines) — Cross-window tab syncing (STAW) +├── tab-lifecycle-timer.ts (65 lines) — Auto-sleep/archive background task +├── core/ +│ ├── tab.ts (805 lines) — Tab entity (view, state, lifecycle) +│ ├── tab-layout-node.ts (201 lines) — Display grouping (single/glance/split) +│ ├── pinned-tab.ts (135 lines) — Pinned tab entity +│ ├── recently-closed-manager.ts (51 lines) — Undo-close ring buffer +│ ├── tab-context-menus.ts (149 lines) — Right-click menus +│ ├── web-context-menu.ts (358 lines) — Page content context menu +│ └── save-image-as.ts (134 lines) — Image download logic +├── layout/ +│ ├── tab-layout.ts (307 lines) — Per-window layout state +│ └── tab-positioner.ts (70 lines) — Tab ordering within spaces +├── persistence/ +│ ├── tab-persistence-service.ts (329 lines) — Dirty-tracked DB persistence +│ └── pinned-tab-persistence.ts (49 lines) — Pinned tab DB operations +└── ipc/ + ├── tab-ipc.ts (513 lines) — IPC handlers + debounced renderer updates + └── preload-api.ts (109 lines) — Renderer-exposed API surface +``` + +--- + +## Core Entities + +### Tab (`core/tab.ts`) + +The fundamental unit. Owns: + +- **Identity:** `id` (counter-based), `uniqueId` (UUID for persistence), `profileId`, `spaceId` +- **Ownership:** `owner: TabOwnerRef` — `{ kind: "normal" }`, `{ kind: "pinned", pinnedTabId }`, or `{ kind: "bookmark", bookmarkId }` (future) +- **View:** Nullable `WebContentsView`, `WebContents`, and `Layer` (null when asleep) +- **State:** `visible`, `fullScreen`, `isPictureInPicture`, `asleep`, `lastActiveAt`, `position` +- **Content:** `title`, `url`, `isLoading`, `audible`, `muted`, `navHistory`, `navHistoryIndex` + +Key lifecycle: + +``` +create → [asleep] → wakeUp → active/inactive → putToSleep → [asleep] → wakeUp → ... → destroy +``` + +Sleep mode destroys the `WebContentsView` entirely, saving ~20-50MB RAM per tab. Navigation history is captured before sleep and restored on wake. + +### TabLayoutNode (`core/tab-layout-node.ts`) + +Represents one or more tabs displayed together in a window: + +- **`single`** — One tab (default) +- **`glance`** — Two-tab stack: front (85% centered, z10) and back (95% centered, z9) +- **`split`** — Side-by-side (future, structure ready) + +Auto-destroys when empty. Syncs all contained tabs to the same space/window. + +### PinnedTab (`core/pinned-tab.ts`) + +Persistent URL shortcuts, per-profile. Maintains a map of `spaceId → tabId` associations — one live `Tab` instance that "follows" the user across spaces. Pinned tabs always sync across windows regardless of the sync setting. + +### TabLayout (`layout/tab-layout.ts`) + +One per window. Tracks: + +- **`activeNodeMap`** — Which `TabLayoutNode` is visible per space +- **`focusedTabMap`** — Which tab each space "wants" (used by STAW for cross-window state) +- **`activationHistory`** — Stack of previously active nodes per space (for smart tab-switching on close) + +--- + +## Central Orchestrator: TabService (`tab-service.ts`) + +The TabService is the single source of truth for all tab state. It coordinates: + +1. **Tab creation/destruction** — Factory pattern with `createTab()` (public) and `createTabInternal()` (internal, skips profile loading) +2. **Activation** — `activateTab()` wakes sleeping tabs, sets active node, updates visibility, records history, notifies extensions +3. **Visibility management** — `updateTabVisibility()` shows/hides layers based on active node and space context +4. **Space/window transitions** — `moveTabToSpace()`, `setCurrentWindowSpace()`, `migrateTabBetweenLayouts()` +5. **Pinned tab operations** — Create, remove, click, double-click, reorder, cross-space relocation +6. **Event emission** — `structural-change`, `content-change`, `active-changed`, `focused-tab-changed`, `pinned-tab-changed`, `tab-created`, `tab-removed` + +### Key Architectural Decisions + +| Decision | Rationale | +| ------------------------------------------------------ | -------------------------------------------------------------------------------------- | +| Nullable view/webContents on Tab | Sleeping tabs hold no Electron resources; ~20-50MB saved per sleeping tab | +| Counter-based tab IDs | Deterministic, fast, no collision risk within a session | +| `TabOwnerRef` discriminated union | Future-proofs for bookmarks/collections owning tabs | +| `focusedTabMap` separate from `activeNodeMap` | STAW needs to know what a window "wants" even when the tab is physically elsewhere | +| `runTabSyncMutation` queue | Serializes async STAW operations to prevent race conditions | +| Direct `extensions.ctx.store` patch for window mapping | `electron-chrome-extensions` has no public `moveTab()` API; contained to one code path | +| Profile guard on pinned tab relocation | Prevents cross-profile pinned tab moves when switching to a different profile's space | + +--- + +## Cross-Window Tab Sync (STAW) (`tab-sync.ts`) + +When "Sync Tabs Across Windows" is enabled (or for pinned-tab-owned tabs unconditionally): + +1. **Window focus** → `moveActiveTabToWindow()` moves the focused tab's view to the newly focused window +2. **Tab deactivation** → If another window still "wants" that tab (`focusedTabMap`), release it there +3. **Space change** → Reconcile placeholders, move focused tab to the current window + +**Placeholder system:** + +- Before moving a tab, a screenshot is captured via `webContents.capturePage()` +- Stored in-memory via `flow-internal://tab-snapshot` protocol +- Renderer shows the placeholder image at 50% opacity +- Cleared after 180ms when the real tab arrives or space changes + +**Key utility:** `isTabSynced(tab)` — central predicate determining if a tab participates in sync (pinned-owned OR global sync enabled AND not excluded). + +--- + +## Persistence (`persistence/`) + +### TabPersistenceService + +- **Dirty tracking:** Only modified tabs are written +- **Batch flush:** Every 2 seconds, all dirty entries are upserted in a single SQLite transaction +- **Owner-aware:** Only `normal`-owned tabs are persisted; ephemeral (pinned-owned) tabs are excluded and stale records cleaned +- **Window state:** Persists window bounds alongside tabs for restoration +- **Layout nodes:** Multi-tab display groupings (glance/split) are persisted and restored + +### Restore Flow (`saving/tabs/restore.ts`) + +1. Load all persisted tabs +2. Filter: archive (delete) tabs inactive beyond threshold (seconds-based comparison) +3. Pre-load all required profiles +4. Create windows per `windowGroupId`, restoring bounds +5. Create all tabs with `asleep: true` (no views, no activation) +6. Restore layout nodes (multi-tab groupings) + +--- + +## IPC Layer (`ipc/`) + +### TabIPC + +- **Debounced updates:** Structural and content changes are batched (80ms window) before sending to renderer +- **Sync-aware broadcasting:** When sync is enabled, structural changes go to ALL windows (they share the same tab list) +- **Serialization:** Tabs → `TabData`, nodes → `TabLayoutNodeData`, pinned → `PinnedTabData` + +### Preload API + +Exposes to renderer: + +- Tab operations: create, close, switch, move, duplicate, mute, reload, etc. +- Navigation: back, forward, loadURL +- Pinned tabs: click, double-click, create, remove, reorder +- Layout: create groups (glance/split), disband +- Queries: get all tabs, focused tab IDs, active node IDs +- Subscriptions: `onTabsChanged`, `onPinnedTabsChanged`, `onPlaceholderChanged` + +--- + +## LayerManager Integration + +The `LayerManager` (per-window) manages view z-ordering and focus: + +- Tab views are wrapped in `Layer` objects with z-index and focus priority +- **Deferred focus reallocation:** When a layer becomes hidden while the window is NOT focused, focus reallocation is deferred until the window regains focus. This prevents `webContents.focus()` from stealing OS focus. +- `layer.focus()` clears `_focusReallocatePending` — explicit focus assignment cancels any pending reallocation. + +--- + +## Data Flow Diagrams + +### Tab Activation + +``` +User clicks tab in sidebar + → IPC: "tab-service:switch-to-tab" + → TabService.activateTab(tab) + → tab.wakeUp() if asleep (creates view, restores nav history) + → layout.setActiveNode(spaceId, node) + → updates activationHistory + → sets focusedTab + → emits "active-changed" + → updateTabVisibility(windowId, spaceId) + → show tabs in active node, hide others + → extensions.selectTab(webContents) + → tab.recordBrowsingHistoryOnActivationIfNeeded() + → tab.layer.focus() if window is focused +``` + +### Space Switch + +``` +User switches to Space B in Window A + → BrowserWindow.setCurrentSpace(spaceId) + → Passes oldSpaceId to setCurrentWindowSpace() + → TabService.setCurrentWindowSpace(window, spaceId, oldSpaceId) + → Hide all tabs visible in the old space + → Relocate pinned tabs (same profile) from other spaces/windows + → Activate most recently active tab in new space (if no active node) + → updateTabVisibility(windowId, spaceId) + → Auto-PiP for tabs with playing video that became hidden + → tab-sync: handleSpaceChange + → Reconcile placeholders + → Move focused tab to this window (if sync enabled) +``` + +### STAW Window Focus Transfer + +``` +User focuses Window B (tab physically in Window A) + → windowsController "window-focused" + → initTabSync handler + → shouldSyncSharedActiveTab(windowB, spaceId) → true + → runTabSyncMutation(async () => { + captureTabScreenshot(tab) // screenshot for Window A + sendPlaceholderToRenderer(windowA) // Window A shows thumbnail + migrateTabBetweenLayouts(tab, windowB.id) + prepareTabForWindowTransfer(tab) // hide + tab.setWindow(windowB) // move layer, patch extensions store + activateTab(tab) // show in Window B + }) +``` + +--- + +## Design Constraints & Known Limitations + +1. **`electron-chrome-extensions` internal patch:** The library has no `moveTab(wc, newWindow)` API. We patch `store.tabToWindow` directly. If the library changes its internals, this breaks. Recommended: fork the library and expose a public method. + +2. **Single live tab per pinned tab:** A pinned tab has exactly one associated `Tab` instance across all spaces/windows. This means switching spaces always moves (not duplicates) the tab. + +3. **Counter-based IDs are session-scoped:** Tab IDs reset on restart. Persistence uses `uniqueId` (UUID) for cross-session identity. + +4. **No undo for pinned tab removal:** `removePinnedTab` destroys the associated tab immediately. Could be improved with the recently-closed system. + +5. **`batchMoveTabs` is simplified:** Used only for renderer-initiated drag-and-drop. Doesn't clear `focusedTabMap` or trigger STAW reconciliation (assumes renderer handles its own state refresh). + +--- + +## Future Considerations + +- **Tab Groups (folder-like):** `TabOwnerRef` is designed to support `{ kind: "group", groupId }` for tree-structured tab organization +- **Bookmark-owned tabs:** `{ kind: "bookmark", bookmarkId }` would allow bookmarks to "own" a live tab (like pinned tabs but with bookmark metadata) +- **Split view:** `TabLayoutNode` already supports the `"split"` mode enum; bounds calculation logic can be added +- **Tab search/filtering:** The `tabs` Map on TabService provides O(1) lookup; space-scoped queries use `getTabsInWindowSpace` +- **Vertical tabs:** Layout/rendering is purely a renderer concern; the service layer is agnostic to tab bar orientation diff --git a/docs/tab-service-migration-checklist.md b/docs/tab-service-migration-checklist.md new file mode 100644 index 000000000..3f26094b0 --- /dev/null +++ b/docs/tab-service-migration-checklist.md @@ -0,0 +1,143 @@ +# Tab Service v2 Migration Checklist + +## Overview + +This document compares all functionality in the **old Tab Manager** (`controllers/tabs-controller` + `controllers/pinned-tabs-controller`, ~1850 lines combined) with the **new Tab Service** (`services/tab-service/`, ~1800 lines in `tab-service.ts` + supporting files). + +--- + +## ✅ Successfully Migrated + +### Tab CRUD & Lifecycle + +| Feature | Old Location | New Location | Notes | +| ---------------------------- | --------------------------------------- | --------------------------------------------------- | -------------------------------------- | +| Tab creation (internal) | `internalCreateTab` | `createTabInternal` | Same logic, cleaner separation | +| Tab creation (public/async) | `createTab` | `createTab` | Profile/space resolution unchanged | +| Tab destruction | `removeTab` + `tab.destroy()` | `destroyTab` + `"destroyed"` handler | Handled via event in wireTabEvents | +| Tab sleep/wake | `TabLifecycleManager.putToSleep/wakeUp` | `Tab.putToSleep/wakeUp` | Moved into Tab class | +| Periodic auto-sleep/archive | `setInterval` in constructor | `tab-lifecycle-timer.ts` | Dedicated module, same 10s interval | +| Tab persistence (save) | `persistTab` → `tabPersistenceManager` | `TabPersistenceService` | New dedicated service | +| Tab serialization | `serializeTab` utility | `Tab.serialize()` + cache | Per-tab cache for performance | +| Recently closed | `recentlyClosedManager` singleton | `RecentlyClosedManager` class on TabService | Inline class | +| Ephemeral tabs | `makeTabEphemeral/makeTabPersistent` | `tab.owner.kind` property | Typed ownership model replaces boolean | +| Tab `updateTabState` polling | webContents event listeners | Same event listeners in `Tab.wireWebContentsEvents` | Identical approach | + +### Active Tab Management + +| Feature | Old Location | New Location | Notes | +| ----------------------------- | --------------------------------- | --------------------------------------- | ------------------------------------------- | +| Activate tab | `activateTab` → `setActiveTab` | `activateTab` | Direct activation, no separate setActiveTab | +| Focused tab management | `setFocusedTab/removeFocusedTab` | `layout.setFocusedTab/removeFocusedTab` | Moved to per-layout | +| Activation history (MRU) | `spaceActivationHistory` map | `TabLayout._activationHistory` | Per-layout now | +| Remove active + select next | `removeActiveTab` | `layout.removeActiveAndSelectNext` | Same history-first, then position fallback | +| Activate next/previous tab | `activateNextTabInSpace/Previous` | `activateNextTab/activatePreviousTab` | Same wrap-around logic | +| `isTabActive` check | `spaceActiveTabMap` lookup | Checks all layouts in window | Handles multi-layout membership | +| `isTabVisibleInAnotherWindow` | Checks other windows' active tabs | Same check + uses `layout.visible` | Simplified for multi-layout | + +### Window/Space Management + +| Feature | Old Location | New Location | Notes | +| --------------------------- | -------------------------------------- | --------------------------- | ------------------------------------- | +| Set current window space | `setCurrentWindowSpace` | `setCurrentWindowSpace` | Same logic + layout visibility toggle | +| Process active tab change | `processActiveTabChange` | `updateTabVisibility` | Visibility + bounds delegation | +| Space deletion cleanup | `spacesController.on("space-deleted")` | Same event handler | Destroys orphaned tabs | +| Window entries cleanup | `cleanupWindowEntries` | `removeAllLayoutsForWindow` | Called on window close | +| Popup window reconciliation | `reconcilePopupWindow` | `reconcilePopupWindow` | Same auto-close + best-target logic | +| Page bounds changed | `handlePageBoundsChanged` | `handlePageBoundsChanged` | Delegates to layout.applyBounds | + +### Tab Groups (now TabLayoutNode) + +| Feature | Old Location | New Location | Notes | +| --------------------------- | ------------------------------------ | -------------------------------- | ---------------------------- | +| Create group (glance/split) | `createTabGroup` | `createLayoutNode` | Same concept, different name | +| Destroy group | `destroyTabGroup` | `destroyLayoutNode` | Layout handles cleanup | +| Group events (changed) | `tabGroup.on("changed")` | Layout structural changes | Folded into layout emission | +| Group persistence | `tabPersistenceManager.saveTabGroup` | `TabPersistenceService` | Saves node mode & tab IDs | +| Glance front tab | `GlanceTabGroup.setFrontTab` | `TabLayoutNode.setFrontTab` | Same concept | +| Split bounds | `SplitTabGroup` bounds logic | `TabLayoutNode.computeTabBounds` | Inline in node | + +### Tab Properties & Events + +| Feature | Old Location | New Location | Notes | +| -------------------------- | ---------------------------------- | -------------------------------- | -------------------------------- | +| Tab position normalization | `normalizePositions` | `positioner.normalizePositions` | Dedicated TabPositioner class | +| Tab move (reorder) | `updateStateProperty("position")` | `moveTab` + normalize | Explicit API | +| Tab move to space | Manual space change | `moveTabToSpace` | Full layout migration | +| Batch move tabs | N/A (done tab-by-tab) | `batchMoveTabs` | New optimization | +| Tab content changes | `windowTabContentChanged` | `emitContentChange` | Debounced + cached | +| Tab structural changes | `windowTabsChanged` | `emitStructuralChange` | Debounced with batch suppression | +| Picture-in-Picture | `disablePictureInPicture` | `disablePictureInPicture` | Same logic | +| Set muted | Direct `webContents.setAudioMuted` | `setTabMuted` → `updateTabState` | Now emits content change | + +### Pinned Tabs + +| Feature | Old Location | New Location | Notes | +| ------------------------- | ------------------------------------ | ------------------------------------------------- | ------------------------------- | +| Create pinned tab | `pinnedTabsController.create` | `tabService.createPinnedTab` | Same DB write + normalize | +| Remove pinned tab | `pinnedTabsController.remove` | `tabService.removePinnedTab` | Destroys associated tabs | +| Reorder pinned tab | `pinnedTabsController.reorder` | `tabService.reorderPinnedTab` | Same normalize logic | +| Update favicon | `pinnedTabsController.updateFavicon` | `PinnedTab.updateFavicon` | On the OOP object now | +| Associate/dissociate tabs | `associateTab/dissociateTab` maps | `PinnedTab.associate/dissociate` | Encapsulated in PinnedTab class | +| Per-space associations | `Map>` | `PinnedTab._associatedTabs` | Same per-space model | +| Reverse lookup by tab ID | `reverseAssociations` map | `tabService.getPinnedTabByAssociatedTabId` | Iterates pinned tabs | +| Click pinned tab | External IPC handler | `tabService.clickPinnedTab` | Full lifecycle + placeholder | +| Pinned node propagation | N/A (old had per-space instances) | `propagatePinnedTabNode` + `PinnedTab.layoutNode` | Multi-layout membership | + +### IPC & Renderer Communication + +| Feature | Old Location | New Location | Notes | +| ----------------------- | ------------------------- | ------------------------------------ | ------------------------------- | +| Window tab data payload | `windowTabsChanged` | `getWindowTabsPayload` + debounce | Serialization cache | +| Content-only updates | `windowTabContentChanged` | `emitContentChange` + dirty tracking | Only re-serializes changed tabs | +| Pinned tab data | Separate IPC endpoint | Included in `getWindowTabsPayload` | Unified payload | + +### Extension Integration + +| Feature | Old Location | New Location | Notes | +| -------------------------- | -------------------------------------------------- | -------------------------------------------------------- | --------------------------------------------------- | +| `extensions.addTab` | In old Tab constructor | `Tab.createView` / `Tab.wakeUp` | Called when webContents exists | +| `extensions.removeTab` | On tab destroy | `Tab.teardownView` | Before view disposal | +| `extensions.selectTab` | On activate | `activateTab` → `tab.loadedProfile.extensions.selectTab` | Same trigger | +| `tab-updated` emission | `setupTabLevelListeners` → `on("updated")` handler | `wireTabEvents` → `on("updated")` handler | Was lost in migration, now restored | +| `assignTabDetails` (index) | N/A (was always -1) | `tabService.getTabIndexInWindowProfile(tab)` | **NEW** - proper index via `getTabsInWindowProfile` | +| Index change notification | Never existed | `notifyIndexChanges` on create/destroy/move | **NEW** - all tabs in profile get notified | + +--- + +## 🔧 Fixed In This Commit (Previously Missing) + +| Feature | Issue | Fix | +| ---------------------------------------------- | ------------------------------------------------------------------------- | -------------------------------------------------------------------------- | +| Extension state update on tab property changes | Old `setupTabLevelListeners` emitted `tab-updated`; lost during migration | Added `notifyExtensionsOfChanges()` in `wireTabEvents` `"updated"` handler | +| Extension index update on tab creation | New tab shifts indices of existing tabs | Added `notifyIndexChanges` after `createTabInternal` | +| Extension index update on tab destruction | Remaining tabs shift indices | Added `notifyIndexChanges` in `"destroyed"` handler | +| Extension index update on cross-window move | Tab moves between windows shifts indices in both | Added `notifyIndexChanges` for both old and new window | +| Extension index update on space move | `moveTabToSpace` shifts indices | Added `notifyIndexChanges` after normalize | +| Extension index update on batch move | `batchMoveTabs` shifts indices | Added `notifyIndexChanges` for affected profiles | + +--- + +## ⏭️ Intentionally Not Migrated + +| Feature | Reason | +| -------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | +| `TabLayoutManager` (per-tab layout helper) | Replaced by centralized `TabLayout.applyBounds` + `TabLayoutNode.computeTabBounds` | +| `TabBoundsController` (per-tab bounds) | Same — bounds calculation is now layout-level with per-node secondary calculation | +| `TabLifecycleManager.setupFullScreenListeners` | Moved to `Tab.setupWindowFullScreenListener` (self-contained) | +| Separate `windowTabsChanged`/`windowTabContentChanged` IPC functions | Replaced by internal event system → debounced IPC emission via `processQueues` | +| `tabPersistenceManager` singleton | Replaced by `TabPersistenceService` class instantiated by TabService | +| `shouldPersistTab` as standalone function | Now: `tab.owner.kind === "normal"` + tab/window type checks inline | +| `registerTabsController` for tab-sync | Tab sync is now integrated via hooks directly in TabService | +| `getTabGroupByTabId` / `getTabGroupById` (string IDs) | Groups are now `TabLayoutNode`s accessed via layout; no separate registry | +| `tabGroupCounter` (string ID generation) | Nodes use integer IDs generated by TabLayout | +| `removeFromActivationHistory` (by string group ID) | Activation history is per-layout; node destruction auto-cleans | + +--- + +## Notes + +- The old `Tab` class lived in `controllers/tabs-controller/tab.ts`; the new one is at `services/tab-service/core/tab.ts` and is significantly more self-contained (owns its own webContents lifecycle, view creation/teardown, fullscreen, PiP). +- The old context menu was a separate file (`context-menu.ts`); the new one is split into `web-context-menu.ts` (page right-click) and `tab-context-menus.ts` (sidebar tab item right-click). +- The old `Tab` class emitted `"tab-updated"` on webContents inside `setupTabLevelListeners()` whenever the `"updated"` event fired. This was lost during the Tab Service v2 migration. The new `Tab.notifyExtensionsOfChanges()` method restores this behavior and is called from `wireTabEvents`. +- The `electron-chrome-extensions` library automatically handles `did-start-navigation`, `did-redirect-navigation`, `did-navigate-in-page`, `page-favicon-updated`, and `page-title-updated` events. The custom `"tab-updated"` event (via `notifyExtensionsOfChanges`) is for everything else — muted state, discarded state, index changes, and any property not covered by built-in Electron events. diff --git a/package.json b/package.json index c0eb8c4e7..0c86c6a83 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,7 @@ "arktype": "^2.2.0", "better-sqlite3": "^12.9.0", "drizzle-orm": "^0.45.2", - "electron-chrome-extensions": "npm:@iamevan/electron-chrome-extensions@4.9.4", + "electron-chrome-extensions": "npm:@iamevan/electron-chrome-extensions@4.9.5", "electron-chrome-web-store": "npm:@iamevan/electron-chrome-web-store@0.13.3", "electron-context-menu": "^4.1.2", "electron-updater": "^6.8.3", diff --git a/src/main/app/basic-auth.ts b/src/main/app/basic-auth.ts index 5ff69c8d9..7baba0d5a 100644 --- a/src/main/app/basic-auth.ts +++ b/src/main/app/basic-auth.ts @@ -1,5 +1,5 @@ import { app } from "electron"; -import { tabsController } from "@/controllers/tabs-controller"; +import { tabService } from "@/services/tab-service"; import { queuePrompt } from "@/modules/prompts"; import type { BasicAuthCredentials, PromptResult, PromptState } from "~/types/prompts"; @@ -10,7 +10,7 @@ export function setupBasicAuthHandler() { return; } - const tabId = tabsController.getTabByWebContents(webContents)?.id; + const tabId = tabService.getTabByWebContents(webContents)?.id; if (!tabId) { callback(); return; diff --git a/src/main/app/urls.ts b/src/main/app/urls.ts index 7398c7f92..f0d5b3f39 100644 --- a/src/main/app/urls.ts +++ b/src/main/app/urls.ts @@ -1,4 +1,4 @@ -import { tabsController } from "@/controllers/tabs-controller"; +import { tabService } from "@/services/tab-service"; import { browserWindowsController } from "@/controllers/windows-controller/interfaces/browser"; import { hasCompletedOnboarding } from "@/saving/onboarding"; import { debugPrint } from "@/modules/output"; @@ -55,8 +55,8 @@ async function openUrlInWindow(useNewWindow: boolean, url: string) { window.show(true); // Create a new tab with the URL - const tab = await tabsController.createTab(window.id, undefined, undefined, undefined, { url }); - tabsController.activateTab(tab); + const tab = await tabService.createTab(window.id, undefined, undefined, undefined, { url }); + tabService.activateTab(tab); } /** diff --git a/src/main/browser.ts b/src/main/browser.ts index c8a5f0f30..5ace7edce 100644 --- a/src/main/browser.ts +++ b/src/main/browser.ts @@ -12,30 +12,22 @@ import { processInitialUrl } from "@/app/urls"; import { setupSecondInstanceHandling } from "@/app/instance"; import { runOnboardingOrInitialWindow } from "@/app/onboarding"; import { setupAppLifecycle } from "@/app/lifecycle"; -import { tabPersistenceManager } from "@/saving/tabs"; import { initCursorEdgeMonitor } from "@/controllers/windows-controller/utils/cursor-edge-monitor"; import { cleanupStaleEphemeralProfiles } from "@/controllers/profiles-controller/ephemeral"; -import { initTabSync } from "@/controllers/tabs-controller/tab-sync"; -import { pinnedTabsController } from "@/controllers/pinned-tabs-controller"; import { setupBasicAuthHandler } from "@/app/basic-auth"; +import { initializeTabService } from "@/services/tab-service"; async function bootstrapBrowser() { await cleanupStaleEphemeralProfiles().catch((error) => { console.error("Failed to cleanup stale ephemeral profiles:", error); }); - // Start tab persistence flush interval (writes dirty tabs to disk every ~2s) - tabPersistenceManager.start(); - - // Load pinned tabs from database into memory (synchronous — better-sqlite3) - pinnedTabsController.loadAll(); + // Initialize Tab Service v2 (registers IPC handlers, starts persistence flush, loads pinned tabs) + initializeTabService(); // Start cursor edge monitor (detects pointer near window edges for floating sidebar) initCursorEdgeMonitor(); - // Initialize tab sync (handles moving active tabs between windows when sync enabled) - initTabSync(); - // Handle initial URL (runs asynchronously) processInitialUrl(); diff --git a/src/main/controllers/app-menu-controller/index.ts b/src/main/controllers/app-menu-controller/index.ts index 649bb07bd..718886ad0 100644 --- a/src/main/controllers/app-menu-controller/index.ts +++ b/src/main/controllers/app-menu-controller/index.ts @@ -9,7 +9,7 @@ import { createViewMenu } from "./menu/items/view"; import { createWindowMenu } from "./menu/items/window"; import { MenuItem, MenuItemConstructorOptions } from "electron"; import { shortcutsEmitter } from "@/saving/shortcuts"; -import { recentlyClosedManager } from "@/controllers/tabs-controller/recently-closed-manager"; +import { tabService } from "@/services/tab-service"; import { spacesController } from "@/controllers/spaces-controller"; import { windowsController } from "@/controllers/windows-controller"; @@ -22,7 +22,7 @@ class AppMenuController { spacesController.on("space-deleted", this.render); shortcutsEmitter.on("shortcuts-changed", this.render); - recentlyClosedManager.on("changed", this.render); + tabService.recentlyClosed.on("changed", this.render); // This module hasn't loaded yet, so we have to wait setImmediate(() => { diff --git a/src/main/controllers/app-menu-controller/menu/helpers.ts b/src/main/controllers/app-menu-controller/menu/helpers.ts index 3f15d8696..13585b378 100644 --- a/src/main/controllers/app-menu-controller/menu/helpers.ts +++ b/src/main/controllers/app-menu-controller/menu/helpers.ts @@ -1,4 +1,4 @@ -import { tabsController } from "@/controllers/tabs-controller"; +import { tabService } from "@/services/tab-service"; import { browserWindowsManager, windowsController } from "@/controllers/windows-controller"; import { BaseWindow } from "@/controllers/windows-controller/types"; import { WebContents } from "electron"; @@ -29,7 +29,7 @@ export const getTab = (window?: BaseWindow) => { const spaceId = window.currentSpaceId; if (!spaceId) return null; - const tab = tabsController.getFocusedTab(windowId, spaceId); + const tab = tabService.getFocusedTab(windowId, spaceId); if (!tab) return null; return tab; }; diff --git a/src/main/controllers/app-menu-controller/menu/items/file.ts b/src/main/controllers/app-menu-controller/menu/items/file.ts index 670cb2323..bea8d4961 100644 --- a/src/main/controllers/app-menu-controller/menu/items/file.ts +++ b/src/main/controllers/app-menu-controller/menu/items/file.ts @@ -5,8 +5,7 @@ import { getCurrentShortcut } from "@/modules/shortcuts"; import { browserWindowsController } from "@/controllers/windows-controller/interfaces/browser"; import { createIncognitoWindow } from "@/modules/incognito/windows"; import { FLAGS } from "@/modules/flags"; -import { recentlyClosedManager } from "@/controllers/tabs-controller/recently-closed-manager"; -import { restoreMostRecentClosedTabInWindow } from "@/controllers/tabs-controller/recently-closed"; +import { tabService } from "@/services/tab-service"; export const createFileMenu = (): MenuItemConstructorOptions => ({ label: "File", @@ -23,13 +22,16 @@ export const createFileMenu = (): MenuItemConstructorOptions => ({ { label: "Reopen Closed Tab", accelerator: getCurrentShortcut("tab.reopenClosed"), - enabled: recentlyClosedManager.hasEntries(), + enabled: tabService.recentlyClosed.hasEntries(), click: () => { const window = getFocusedBrowserWindow(); if (!window) return; - void restoreMostRecentClosedTabInWindow(window).catch((error) => { - console.error("Failed to restore most recent closed tab:", error); - }); + const mostRecent = tabService.recentlyClosed.getAll()[0]; + if (mostRecent) { + void tabService.restoreRecentlyClosed(mostRecent.tabData.uniqueId, window).catch((error) => { + console.error("Failed to restore most recent closed tab:", error); + }); + } } }, { diff --git a/src/main/controllers/app-menu-controller/menu/items/tabs.ts b/src/main/controllers/app-menu-controller/menu/items/tabs.ts index 2f82ac5e2..0ac9e5c24 100644 --- a/src/main/controllers/app-menu-controller/menu/items/tabs.ts +++ b/src/main/controllers/app-menu-controller/menu/items/tabs.ts @@ -1,20 +1,20 @@ import { MenuItemConstructorOptions } from "electron"; import { getFocusedBrowserWindow } from "../helpers"; -import { tabsController } from "@/controllers/tabs-controller"; +import { tabService } from "@/services/tab-service"; import { getCurrentShortcut } from "@/modules/shortcuts"; export function menuNextTab() { const window = getFocusedBrowserWindow(); const spaceId = window?.currentSpaceId; if (!window || !spaceId) return; - tabsController.activateNextTabInSpace(window.id, spaceId); + tabService.activateNextTab(window.id, spaceId); } export function menuPreviousTab() { const window = getFocusedBrowserWindow(); const spaceId = window?.currentSpaceId; if (!window || !spaceId) return; - tabsController.activatePreviousTabInSpace(window.id, spaceId); + tabService.activatePreviousTab(window.id, spaceId); } export const createTabsMenu = (): MenuItemConstructorOptions => ({ diff --git a/src/main/controllers/handoff-controller/index.ts b/src/main/controllers/handoff-controller/index.ts index c1460f04b..2b966f5f3 100644 --- a/src/main/controllers/handoff-controller/index.ts +++ b/src/main/controllers/handoff-controller/index.ts @@ -1,5 +1,4 @@ -import { Tab } from "@/controllers/tabs-controller/tab"; -import { tabsController } from "@/controllers/tabs-controller"; +import { Tab, tabService } from "@/services/tab-service"; import { browserWindowsController } from "@/controllers/windows-controller/interfaces/browser"; import { app } from "electron"; @@ -36,24 +35,24 @@ class HandoffController { } private observeExistingTabs() { - for (const tab of tabsController.tabs.values()) { + for (const tab of tabService.tabs.values()) { this.observeTab(tab); } } private observeTabLifecycle() { - tabsController.on("tab-created", (tab) => { + tabService.on("tab-created", (tab) => { this.observeTab(tab); }); } private observeTabStateChanges() { - tabsController.on("active-tab-changed", (windowId, spaceId) => { + tabService.on("active-changed", (windowId, spaceId) => { this.syncFocusedWindowHandoffActivity(windowId, spaceId, "active-tab-changed"); }); - tabsController.on("current-space-changed", (windowId, spaceId) => { - this.syncFocusedWindowHandoffActivity(windowId, spaceId, "current-space-changed"); + tabService.on("focused-tab-changed", (windowId, spaceId) => { + this.syncFocusedWindowHandoffActivity(windowId, spaceId, "active-tab-changed"); }); } @@ -98,28 +97,8 @@ class HandoffController { } private getDisplayedTab(windowId: number, spaceId: string): Tab | undefined { - const activeTabOrGroup = tabsController.getActiveTab(windowId, spaceId); - if (!activeTabOrGroup) { - return undefined; - } - - if (activeTabOrGroup instanceof Tab) { - return activeTabOrGroup; - } - - const focusedTab = tabsController.getFocusedTab(windowId, spaceId); - if (focusedTab && activeTabOrGroup.hasTab(focusedTab.id)) { - return focusedTab; - } - - if (activeTabOrGroup.mode === "glance") { - const frontTab = tabsController.getTabById(activeTabOrGroup.frontTabId); - if (frontTab && activeTabOrGroup.hasTab(frontTab.id)) { - return frontTab; - } - } - - return activeTabOrGroup.tabs[0]; + // In the new system, focused tab is the displayed tab + return tabService.getFocusedTab(windowId, spaceId); } private syncFocusedWindowHandoffActivity( @@ -137,7 +116,7 @@ class HandoffController { return; } - const currentSpaceId = tabsController.windowActiveSpaceMap.get(windowId); + const currentSpaceId = browserWindowsController.getWindowById(windowId)?.currentSpaceId; if (currentSpaceId && currentSpaceId !== spaceId) { return; } diff --git a/src/main/controllers/index.ts b/src/main/controllers/index.ts index 4c880773e..57e4dc018 100644 --- a/src/main/controllers/index.ts +++ b/src/main/controllers/index.ts @@ -18,7 +18,6 @@ import "./posthog-controller"; import "./quit-controller"; import "./auto-update-controller"; import "./loaded-profiles-controller"; -import "./tabs-controller"; -import "./pinned-tabs-controller"; + import "./handoff-controller"; import "./sessions-controller"; diff --git a/src/main/controllers/loaded-profiles-controller/index.ts b/src/main/controllers/loaded-profiles-controller/index.ts index 4ce75b6c2..26bde6e8f 100644 --- a/src/main/controllers/loaded-profiles-controller/index.ts +++ b/src/main/controllers/loaded-profiles-controller/index.ts @@ -1,7 +1,8 @@ import { transformUserAgentHeader } from "@/modules/user-agent"; import { ProfileData, profilesController } from "@/controllers/profiles-controller"; import { sessionsController } from "@/controllers/sessions-controller"; -import { NEW_TAB_URL, tabsController } from "@/controllers/tabs-controller"; +import { tabService } from "@/services/tab-service"; +import { NEW_TAB_URL } from "@/services/tab-service/tab-service"; import { windowsController } from "@/controllers/windows-controller"; import { browserWindowsController } from "@/controllers/windows-controller/interfaces/browser"; import { setWindowSpace } from "@/ipc/session/spaces"; @@ -125,7 +126,7 @@ class LoadedProfilesController extends TypedEventEmitter { - const tab = tabsController.getTabByWebContents(tabWebContents); + const tab = tabService.getTabByWebContents(tabWebContents); if (!tab) return; tabDetails.title = tab.title; @@ -133,6 +134,8 @@ class LoadedProfilesController extends TypedEventEmitter { - const tab = tabsController.getTabByWebContents(tabWebContents); + const tab = tabService.getTabByWebContents(tabWebContents); if (!tab) return; - // Set the space for the window + // Set the space for the window — but only if the tab's space differs + // from the current space AND the tab isn't a multi-layout node (pinned tabs). + // Pinned tab nodes span all spaces, so their tab.spaceId is just the + // creation space and shouldn't force a space switch. const window = tab.getWindow(); - setWindowSpace(window, tab.spaceId); + if (window.destroyed) return; + const currentSpaceId = window.currentSpaceId; + if (currentSpaceId !== tab.spaceId) { + // Check if this tab's node is already in the current space's layout + const currentLayout = tabService.getLayout(window.id, currentSpaceId!); + const nodeInCurrentLayout = currentLayout?.getNodeForTab(tab.id); + if (!nodeInCurrentLayout) { + // Tab is not in the current space's layout — switch space + setWindowSpace(window, tab.spaceId); + } + } // Set the active tab - tabsController.activateTab(tab); + tabService.activateTab(tab); }, removeTab: (tabWebContents) => { - const tab = tabsController.getTabByWebContents(tabWebContents); + const tab = tabService.getTabByWebContents(tabWebContents); if (!tab) return; tab.destroy(); @@ -184,14 +203,10 @@ class LoadedProfilesController extends TypedEventEmitter { - if (currentTabIndex === 0) { - tabsController.activateTab(tab); - } - }); - + const tab = await tabService.createTab(window.id, profileId, undefined, undefined, { url }); + if (tabIndex === 0) { + tabService.activateTab(tab); + } tabIndex++; } } @@ -382,7 +397,7 @@ class LoadedProfilesController extends TypedEventEmitter { tab.destroy(); }); diff --git a/src/main/controllers/pinned-tabs-controller/index.ts b/src/main/controllers/pinned-tabs-controller/index.ts deleted file mode 100644 index 7ae3c6b76..000000000 --- a/src/main/controllers/pinned-tabs-controller/index.ts +++ /dev/null @@ -1,359 +0,0 @@ -import { getDb, schema } from "@/saving/db"; -import { generateID } from "@/modules/utils"; -import { eq } from "drizzle-orm"; -import { PersistedPinnedTabData, PinnedTabData } from "~/types/pinned-tabs"; -import { TypedEventEmitter } from "@/modules/typed-event-emitter"; - -type PinnedTabsControllerEvents = { - changed: []; -}; - -/** - * Manages persistence and runtime state of pinned tabs. - * - * Pinned tabs are persistent URL shortcuts tied to a profile. - * They are stored in a separate `pinned_tabs` table and associated - * with live browser tabs at runtime via an in-memory map. - * - * Each pinned tab can have one associated tab per space, allowing - * each space to have its own instance of a pinned tab. - * - * All database writes are immediate (pinned tabs change infrequently). - */ -class PinnedTabsController extends TypedEventEmitter { - /** In-memory cache of all pinned tabs, keyed by uniqueId */ - private pinnedTabs = new Map(); - - /** - * Runtime associations: pinnedTabId → spaceId → browser tab ID - * Each pinned tab can have one associated tab per space. - */ - private associations = new Map>(); - - /** - * Reverse lookup: browser tab ID → { pinnedTabId, spaceId } - */ - private reverseAssociations = new Map(); - - // --- Initialization --- - - /** - * Load all pinned tabs from the database into memory. - * Should be called once during app startup. - */ - loadAll(): void { - const db = getDb(); - const rows = db.select().from(schema.pinnedTabs).all(); - this.pinnedTabs.clear(); - for (const row of rows) { - const data: PersistedPinnedTabData = { ...row }; - this.pinnedTabs.set(data.uniqueId, data); - } - } - - // --- CRUD Operations --- - - /** - * Create a new pinned tab. - * @returns The created pinned tab data - */ - create(profileId: string, defaultUrl: string, faviconUrl: string | null, position?: number): PersistedPinnedTabData { - const uniqueId = generateID(); - - let finalPosition: number; - if (position !== undefined) { - // Use the requested position (fractional is fine, normalizePositions will fix it) - finalPosition = position; - } else { - // Place at the end - let maxPosition = -1; - for (const pt of this.pinnedTabs.values()) { - if (pt.profileId === profileId && pt.position > maxPosition) { - maxPosition = pt.position; - } - } - finalPosition = maxPosition + 1; - } - - const data: PersistedPinnedTabData = { - uniqueId, - profileId, - defaultUrl, - faviconUrl, - position: finalPosition - }; - - // Persist + normalize in a single transaction. - // Add to memory before normalizing so normalizePositionsInTx sees the new - // tab (mirrors how `remove` deletes from memory before normalizing). - const db = getDb(); - db.transaction((tx) => { - tx.insert(schema.pinnedTabs) - .values({ ...data }) - .run(); - this.pinnedTabs.set(uniqueId, data); - this.normalizePositionsInTx(tx, profileId); - }); - - this.emit("changed"); - - return data; - } - - /** - * Remove a pinned tab. - * Returns the associated browser tab IDs (if any) that were cleared during removal. - */ - remove(uniqueId: string): number[] { - const data = this.pinnedTabs.get(uniqueId); - if (!data) return []; - - // Capture and clear all associations before removal - const spaceAssociations = this.associations.get(uniqueId); - const associatedTabIds: number[] = []; - if (spaceAssociations) { - for (const tabId of spaceAssociations.values()) { - associatedTabIds.push(tabId); - this.reverseAssociations.delete(tabId); - } - this.associations.delete(uniqueId); - } - - // Remove from database + normalize in a single transaction - const db = getDb(); - db.transaction((tx) => { - tx.delete(schema.pinnedTabs).where(eq(schema.pinnedTabs.uniqueId, uniqueId)).run(); - // Remove from memory before normalizing so it's excluded - this.pinnedTabs.delete(uniqueId); - this.normalizePositionsInTx(tx, data.profileId); - }); - - this.emit("changed"); - return associatedTabIds; - } - - /** - * Update a pinned tab's position (for reordering). - */ - reorder(uniqueId: string, newPosition: number): void { - const data = this.pinnedTabs.get(uniqueId); - if (!data) return; - - data.position = newPosition; - - // Persist + normalize in a single transaction - const db = getDb(); - db.transaction((tx) => { - tx.update(schema.pinnedTabs).set({ position: newPosition }).where(eq(schema.pinnedTabs.uniqueId, uniqueId)).run(); - this.normalizePositionsInTx(tx, data.profileId); - }); - - this.emit("changed"); - } - - /** - * Update a pinned tab's favicon URL. - */ - updateFavicon(uniqueId: string, faviconUrl: string | null): void { - const data = this.pinnedTabs.get(uniqueId); - if (!data) return; - - data.faviconUrl = faviconUrl; - - // Persist immediately - const db = getDb(); - db.update(schema.pinnedTabs).set({ faviconUrl }).where(eq(schema.pinnedTabs.uniqueId, uniqueId)).run(); - - this.emit("changed"); - } - - // --- Association Management --- - - /** - * Associate a pinned tab with a live browser tab for a specific space. - * Each pinned tab can have one associated tab per space. - */ - associateTab(pinnedId: string, spaceId: string, tabId: number): void { - // Get or create the space->tab mapping for this pinned tab - let spaceAssociations = this.associations.get(pinnedId); - if (!spaceAssociations) { - spaceAssociations = new Map(); - this.associations.set(pinnedId, spaceAssociations); - } - - // Clear any existing association for this tab in the same space - const oldTabId = spaceAssociations.get(spaceId); - if (oldTabId !== undefined && oldTabId !== tabId) { - this.reverseAssociations.delete(oldTabId); - } - - // Clear any existing association for this browser tab (in any space/pinned tab) - const oldAssociation = this.reverseAssociations.get(tabId); - if (oldAssociation !== undefined) { - const oldSpaceAssociations = this.associations.get(oldAssociation.pinnedTabId); - if (oldSpaceAssociations) { - oldSpaceAssociations.delete(oldAssociation.spaceId); - } - } - - spaceAssociations.set(spaceId, tabId); - this.reverseAssociations.set(tabId, { pinnedTabId: pinnedId, spaceId }); - this.emit("changed"); - } - - /** - * Dissociate a pinned tab from its browser tab in a specific space. - */ - dissociateTab(pinnedId: string, spaceId: string): void { - const spaceAssociations = this.associations.get(pinnedId); - if (spaceAssociations) { - const tabId = spaceAssociations.get(spaceId); - if (tabId !== undefined) { - this.reverseAssociations.delete(tabId); - spaceAssociations.delete(spaceId); - this.emit("changed"); - } - } - } - - /** - * Called when a browser tab is destroyed. - * Clears any association pointing to that tab. - */ - onBrowserTabDestroyed(tabId: number): void { - const association = this.reverseAssociations.get(tabId); - if (association !== undefined) { - const spaceAssociations = this.associations.get(association.pinnedTabId); - if (spaceAssociations) { - spaceAssociations.delete(association.spaceId); - } - this.reverseAssociations.delete(tabId); - this.emit("changed"); - } - } - - // --- Query Methods --- - - /** - * Convert space associations map to Record for serialization. - */ - private getAssociatedTabIdsBySpace(pinnedId: string): Record { - const spaceAssociations = this.associations.get(pinnedId); - if (!spaceAssociations) return {}; - const result: Record = {}; - for (const [spaceId, tabId] of spaceAssociations) { - result[spaceId] = tabId; - } - return result; - } - - /** - * Get all pinned tabs for a profile, sorted by position. - */ - getByProfile(profileId: string): PinnedTabData[] { - const result: PinnedTabData[] = []; - for (const data of this.pinnedTabs.values()) { - if (data.profileId === profileId) { - result.push({ - ...data, - associatedTabIdsBySpace: this.getAssociatedTabIdsBySpace(data.uniqueId) - }); - } - } - result.sort((a, b) => a.position - b.position); - return result; - } - - /** - * Get all pinned tabs grouped by profile ID. - */ - getAllByProfile(): Record { - const result: Record = {}; - for (const data of this.pinnedTabs.values()) { - if (!result[data.profileId]) { - result[data.profileId] = []; - } - result[data.profileId].push({ - ...data, - associatedTabIdsBySpace: this.getAssociatedTabIdsBySpace(data.uniqueId) - }); - } - // Sort each profile's pinned tabs by position - for (const profileId of Object.keys(result)) { - result[profileId].sort((a, b) => a.position - b.position); - } - return result; - } - - /** - * Get a single pinned tab by ID. - */ - getById(uniqueId: string): PinnedTabData | null { - const data = this.pinnedTabs.get(uniqueId); - if (!data) return null; - return { - ...data, - associatedTabIdsBySpace: this.getAssociatedTabIdsBySpace(uniqueId) - }; - } - - /** - * Get the associated browser tab ID for a pinned tab in a specific space. - */ - getAssociatedTabId(pinnedId: string, spaceId: string): number | null { - const spaceAssociations = this.associations.get(pinnedId); - if (!spaceAssociations) return null; - return spaceAssociations.get(spaceId) ?? null; - } - - /** - * Get the pinned tab ID and space ID associated with a browser tab. - */ - getPinnedIdByTabId(tabId: number): { pinnedTabId: string; spaceId: string } | null { - return this.reverseAssociations.get(tabId) ?? null; - } - - /** - * Get all associated browser tab IDs for pinned tabs belonging to a profile. - */ - getAssociatedTabIdsForProfile(profileId: string): number[] { - const result: number[] = []; - for (const [pinnedId, spaceAssociations] of this.associations) { - const pinnedTab = this.pinnedTabs.get(pinnedId); - if (pinnedTab && pinnedTab.profileId === profileId) { - for (const tabId of spaceAssociations.values()) { - result.push(tabId); - } - } - } - return result; - } - - // --- Internal helpers --- - - /** - * Normalize positions for a profile's pinned tabs to be contiguous 0, 1, 2, ... - * Accepts a transaction handle so callers can include normalization in an - * atomic operation with the preceding write. - */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - private normalizePositionsInTx(tx: any, profileId: string): void { - const tabs: PersistedPinnedTabData[] = []; - for (const data of this.pinnedTabs.values()) { - if (data.profileId === profileId) { - tabs.push(data); - } - } - tabs.sort((a, b) => a.position - b.position); - - for (let i = 0; i < tabs.length; i++) { - if (tabs[i].position !== i) { - tabs[i].position = i; - tx.update(schema.pinnedTabs).set({ position: i }).where(eq(schema.pinnedTabs.uniqueId, tabs[i].uniqueId)).run(); - } - } - } -} - -// Singleton instance -export const pinnedTabsController = new PinnedTabsController(); diff --git a/src/main/controllers/quit-controller/handlers/before-quit.ts b/src/main/controllers/quit-controller/handlers/before-quit.ts index 3cf0b0147..7de5b60a7 100644 --- a/src/main/controllers/quit-controller/handlers/before-quit.ts +++ b/src/main/controllers/quit-controller/handlers/before-quit.ts @@ -1,5 +1,5 @@ import { loadedProfilesController } from "@/controllers/loaded-profiles-controller"; -import { tabPersistenceManager } from "@/saving/tabs"; +import { tabPersistenceService } from "@/services/tab-service"; import { closeDatabase } from "@/saving/db"; import { closeFaviconsDatabase } from "@/modules/favicons"; import { sleep } from "@/modules/utils"; @@ -31,7 +31,7 @@ async function flushSessionsData() { // If the handler returns false, the quit will be cancelled export function beforeQuit(): boolean | Promise { // Flush all pending tab saves before quitting - const flushTabsPromise = tabPersistenceManager + const flushTabsPromise = tabPersistenceService .stop() .then(() => { // Close the database connection cleanly after tabs are flushed diff --git a/src/main/controllers/sessions-controller/protocols/_protocols/flow-internal/active-favicon.ts b/src/main/controllers/sessions-controller/protocols/_protocols/flow-internal/active-favicon.ts index 28c54a238..766db3e12 100644 --- a/src/main/controllers/sessions-controller/protocols/_protocols/flow-internal/active-favicon.ts +++ b/src/main/controllers/sessions-controller/protocols/_protocols/flow-internal/active-favicon.ts @@ -1,4 +1,4 @@ -import { tabsController } from "@/controllers/tabs-controller"; +import { tabService } from "@/services/tab-service"; import { HonoApp } from "."; import { getExtensionAsset } from "@/modules/extensions/assets"; import { bufferToArrayBuffer } from "@/modules/utils"; @@ -13,7 +13,7 @@ const inFlightFetches = new Map { for (const [tabId, cached] of activeTabFaviconCache.entries()) { - const tab = tabsController.getTabById(tabId); + const tab = tabService.getTabById(tabId); if (!tab || tab.isDestroyed || tab.faviconURL !== cached.faviconURL) { activeTabFaviconCache.delete(tabId); } @@ -41,7 +41,7 @@ export function registerActiveFaviconRoutes(app: HonoApp) { return c.text("Invalid tab ID", 400); } - const tab = tabsController.getTabById(tabIdInt); + const tab = tabService.getTabById(tabIdInt); if (!tab) { return c.text("No tab found", 404); } diff --git a/src/main/controllers/tabs-controller/bounds.ts b/src/main/controllers/tabs-controller/bounds.ts deleted file mode 100644 index 160c10747..000000000 --- a/src/main/controllers/tabs-controller/bounds.ts +++ /dev/null @@ -1,275 +0,0 @@ -import { Tab } from "./tab"; -import { Rectangle } from "electron"; -import { performance } from "perf_hooks"; - -const FRAME_RATE = 60; -const MS_PER_FRAME = 1000 / FRAME_RATE; -const SPRING_STIFFNESS = 300; -const SPRING_DAMPING = 30; -const MIN_DISTANCE_THRESHOLD = 0.01; -const MIN_VELOCITY_THRESHOLD = 0.01; - -// Type definitions for clarity -type Dimension = "x" | "y" | "width" | "height"; -const DIMENSIONS: Dimension[] = ["x", "y", "width", "height"]; -type Velocity = Record; - -/** - * Helper function to compare two Rectangle objects for equality. - * Handles null cases. - */ -export function isRectangleEqual(rect1: Rectangle | null, rect2: Rectangle | null): boolean { - // If both are the same instance (including both null), they are equal. - if (rect1 === rect2) { - return true; - } - // If one is null and the other isn't, they are not equal. - if (!rect1 || !rect2) { - return false; - } - // Compare properties if both are non-null. - return rect1.x === rect2.x && rect1.y === rect2.y && rect1.width === rect2.width && rect1.height === rect2.height; -} - -/** - * Rounds the properties of a Rectangle object to the nearest integer. - * Returns null if the input is null. - */ -function roundRectangle(rect: Rectangle | null): Rectangle | null { - if (!rect) { - return null; - } - return { - x: Math.round(rect.x), - y: Math.round(rect.y), - width: Math.round(rect.width), - height: Math.round(rect.height) - }; -} - -export class TabBoundsController { - private readonly tab: Tab; - public targetBounds: Rectangle | null = null; - // Current animated bounds (can have fractional values) - public bounds: Rectangle | null = null; - // The last integer bounds actually applied to the view - private lastAppliedBounds: Rectangle | null = null; - private velocity: Velocity = { x: 0, y: 0, width: 0, height: 0 }; - private lastUpdateTime: number | null = null; - private animationFrameId: NodeJS.Timeout | null = null; - - constructor(tab: Tab) { - this.tab = tab; - } - - /** - * Starts the animation loop if it's not already running. - */ - private startAnimationLoop(): void { - if (this.animationFrameId !== null) { - return; // Already running - } - // Ensure we have a valid starting time - if (this.lastUpdateTime === null) { - this.lastUpdateTime = performance.now(); - } - - const loop = () => { - const now = performance.now(); - // Ensure deltaTime is reasonable, capping to avoid large jumps - const deltaTime = this.lastUpdateTime ? Math.min((now - this.lastUpdateTime) / 1000, 1 / 30) : 1 / FRAME_RATE; // Use FRAME_RATE constant - this.lastUpdateTime = now; - - const settled = this.updateBounds(deltaTime); - this.updateViewBounds(); // Apply potentially changed bounds to the view - - if (settled) { - this.stopAnimationLoop(); - } else { - // Schedule next frame using standard setTimeout - this.animationFrameId = setTimeout(loop, MS_PER_FRAME); - } - }; - // Start the loop using standard setTimeout - this.animationFrameId = setTimeout(loop, MS_PER_FRAME); - } - - /** - * Stops the animation loop if it's running. - */ - private stopAnimationLoop(): void { - if (this.animationFrameId !== null) { - clearTimeout(this.animationFrameId); // Only need clearTimeout - this.animationFrameId = null; - this.lastUpdateTime = null; // Reset time tracking when stopped - } - } - - /** - * Sets the target bounds and starts the animation towards them. - * If bounds are already the target, does nothing. - * If bounds are set for the first time, applies them immediately. - * @param bounds The desired final bounds for the tab's view. - */ - public setBounds(bounds: Rectangle): void { - // Don't restart animation if the target hasn't changed - if (this.targetBounds && isRectangleEqual(this.targetBounds, bounds)) { - return; - } - - this.targetBounds = { ...bounds }; // Copy to avoid external mutation - - if (!this.bounds) { - // If this is the first time bounds are set, apply immediately - this.setBoundsImmediate(bounds); - } else { - // Otherwise, start the animation loop to transition - this.startAnimationLoop(); - } - } - - /** - * Sets the bounds immediately, stopping any existing animation - * and directly applying the new bounds to the view. - * @param bounds The exact bounds to apply immediately. - */ - public setBoundsImmediate(bounds: Rectangle): void { - this.stopAnimationLoop(); // Stop any ongoing animation - - const newBounds = { ...bounds }; // Create a copy - this.targetBounds = newBounds; // Update target to match - this.bounds = newBounds; // Update current animated bounds - this.velocity = { x: 0, y: 0, width: 0, height: 0 }; // Reset velocity - - this.updateViewBounds(); // Apply the change to the view - } - - /** - * Applies the current animated bounds (rounded to integers) to the - * actual BrowserView, but only if they have changed since the last application - * or if the tab is not visible. - */ - private updateViewBounds(): void { - // Don't attempt to set bounds if the tab isn't visible or doesn't have bounds yet - // Also check targetBounds to ensure we have a valid state to eventually reach. - if (!this.tab.visible || !this.bounds || !this.targetBounds) { - // If not visible, we might still want to ensure the final state is applied - // if the animation finished while hidden. - if (!this.tab.visible && this.bounds && this.targetBounds && !isRectangleEqual(this.bounds, this.targetBounds)) { - // If hidden but not at target, snap to target and update lastApplied if needed - this.bounds = { ...this.targetBounds }; - const integerBounds = roundRectangle(this.bounds); - if (!isRectangleEqual(integerBounds, this.lastAppliedBounds)) { - // Even though not visible, update lastAppliedBounds to reflect the snapped state - this.lastAppliedBounds = integerBounds; - } - } - return; - } - - // Calculate the integer bounds intended for the view - const integerBounds = roundRectangle(this.bounds); - - // Only call setBounds on the view if the *rounded* bounds have actually changed - if (!isRectangleEqual(integerBounds, this.lastAppliedBounds)) { - if (integerBounds) { - // Ensure integerBounds is not null before setting - this.tab.view?.setBounds(integerBounds); - this.lastAppliedBounds = integerBounds; // Store the bounds that were actually applied - } else { - // If rounding resulted in null (shouldn't happen with valid this.bounds), clear last applied - this.lastAppliedBounds = null; - } - } - } - - /** - * Updates the animated bounds based on spring physics for a given time delta. - * Reduces object allocation by modifying the existing `this.bounds` object. - * @param deltaTime The time elapsed since the last update in seconds. - * @returns `true` if the animation has settled, `false` otherwise. - */ - public updateBounds(deltaTime: number): boolean { - // Stop animation immediately if the tab is no longer visible - if (!this.tab.visible) { - this.stopAnimationLoop(); - // Consider the animation settled if the tab is not visible - return true; - } - - // If target or current bounds are missing, animation cannot proceed - if (!this.targetBounds || !this.bounds) { - this.stopAnimationLoop(); - return true; - } - - let allSettled = true; - - // Iterate over each dimension (x, y, width, height) for physics calculation - for (const dim of DIMENSIONS) { - const targetValue = this.targetBounds[dim]; - const currentValue = this.bounds[dim]; - const currentVelocity = this.velocity[dim]; - - const delta = targetValue - currentValue; - - // Check if this specific dimension is settled - const isDistanceSettled = Math.abs(delta) < MIN_DISTANCE_THRESHOLD; - const isVelocitySettled = Math.abs(currentVelocity) < MIN_VELOCITY_THRESHOLD; - - if (isDistanceSettled && isVelocitySettled) { - // Snap this dimension to the target and zero its velocity - this.bounds[dim] = targetValue; - this.velocity[dim] = 0; - // This dimension is settled, continue checking others - } else { - // If any dimension is not settled, the whole animation is not settled - allSettled = false; - - // Calculate spring forces and update velocity for this dimension - const force = delta * SPRING_STIFFNESS; - const dampingForce = currentVelocity * SPRING_DAMPING; - const acceleration = force - dampingForce; // Mass assumed to be 1 - this.velocity[dim] += acceleration * deltaTime; - - // Update position based on velocity for this dimension - this.bounds[dim] += this.velocity[dim] * deltaTime; - } - } - - // If all dimensions have settled in this frame, ensure exact final state - if (allSettled) { - // This might be slightly redundant if snapping works perfectly, but ensures precision - this.bounds.x = this.targetBounds.x; - this.bounds.y = this.targetBounds.y; - this.bounds.width = this.targetBounds.width; - this.bounds.height = this.targetBounds.height; - this.velocity = { x: 0, y: 0, width: 0, height: 0 }; - } - - return allSettled; // Return true if all dimensions are settled - } - - /** - * Resets the cached last-applied bounds. - * Must be called when the underlying WebContentsView is destroyed (e.g. on - * sleep) so that the next updateViewBounds() call will re-apply bounds to - * the newly created view instead of skipping due to stale equality. - */ - public resetLastAppliedBounds(): void { - this.lastAppliedBounds = null; - } - - /** - * Cleans up resources, stopping the animation loop. - * Should be called when the controller is no longer needed. - */ - public destroy(): void { - this.stopAnimationLoop(); - // Optionally clear references if needed, though JS garbage collection handles this - // this.tab = null; // If Tab has circular refs, might help, but likely not needed - this.targetBounds = null; - this.bounds = null; - this.lastAppliedBounds = null; - } -} diff --git a/src/main/controllers/tabs-controller/index.ts b/src/main/controllers/tabs-controller/index.ts deleted file mode 100644 index fd8994a41..000000000 --- a/src/main/controllers/tabs-controller/index.ts +++ /dev/null @@ -1,1489 +0,0 @@ -import { TypedEventEmitter } from "@/modules/typed-event-emitter"; -import { Tab, TabCreationOptions } from "./tab"; -import { BaseTabGroup, TabGroup } from "./tab-groups"; -import { TabBoundsController } from "./bounds"; -import { TabLayoutManager } from "./tab-layout"; -import { TabLifecycleManager } from "./tab-lifecycle"; -import { windowTabsChanged, windowTabContentChanged } from "@/ipc/browser/tabs"; -import { shouldArchiveTab, shouldSleepTab, tabPersistenceManager } from "@/saving/tabs"; -import { serializeTab, serializeTabGroup } from "@/saving/tabs/serialization"; -import { recentlyClosedManager } from "./recently-closed-manager"; -import { GlanceTabGroup } from "./tab-groups/glance"; -import { SplitTabGroup } from "./tab-groups/split"; -import { browserWindowsController } from "@/controllers/windows-controller/interfaces/browser"; -import { spacesController } from "@/controllers/spaces-controller"; -import { loadedProfilesController } from "@/controllers/loaded-profiles-controller"; -import { setWindowSpace } from "@/ipc/session/spaces"; -import { WebContents } from "electron"; -import { TabGroupMode } from "~/types/tabs"; -import { FLAGS } from "@/modules/flags"; -import { quitController } from "@/controllers/quit-controller"; -import { clearPlaceholdersForTab, isSyncExcludedTab, isTabSyncEnabled, registerTabsController } from "./tab-sync"; - -export const NEW_TAB_URL = "flow://new-tab"; -const ARCHIVE_CHECK_INTERVAL_MS = 10 * 1000; - -type TabsControllerEvents = { - "tab-created": [Tab]; - "tab-removed": [Tab]; - "current-space-changed": [number, string]; - "active-tab-changed": [number, string]; - destroyed: []; -}; - -type WindowSpaceReference = `${number}-${string}`; - -interface PopupWindowReconcileOptions { - preferredSpaceId?: string; - forcePreferredSpace?: boolean; -} - -function shouldPersistTab(tab: Tab): boolean { - if (tab.ephemeral) return false; - if (tab.loadedProfile.profileData.ephemeral) return false; - if (tab.getWindow().browserWindowType === "popup") return false; - return true; -} - -/** - * Per-tab managers that the controller owns. - * Stored alongside each Tab so the controller can call lifecycle/layout methods. - */ -interface TabManagers { - lifecycle: TabLifecycleManager; - layout: TabLayoutManager; - bounds: TabBoundsController; -} - -class TabsController extends TypedEventEmitter { - // Public properties - public tabs: Map; - - // Per-tab managers - private tabManagers: Map = new Map(); - - // Window Space Maps - public windowActiveSpaceMap: Map; - private spaceActiveTabMap: Map; - public spaceFocusedTabMap: Map; - /** Activation history stores both tab IDs (number) and group IDs (string) */ - public spaceActivationHistory: Map; - - // Tab Groups (keyed by string groupId) - public tabGroups: Map; - private tabGroupCounter: number = 0; - - constructor() { - super(); - - this.tabs = new Map(); - - this.windowActiveSpaceMap = new Map(); - this.spaceActiveTabMap = new Map(); - this.spaceFocusedTabMap = new Map(); - this.spaceActivationHistory = new Map(); - - this.tabGroups = new Map(); - this.tabGroupCounter = 0; - - // Setup event listeners - this.on("active-tab-changed", (windowId, spaceId) => { - if (quitController.isQuitting) return; - this.processActiveTabChange(windowId, spaceId); - windowTabsChanged(windowId); - }); - - this.on("current-space-changed", (windowId, spaceId) => { - if (quitController.isQuitting) return; - this.processActiveTabChange(windowId, spaceId); - windowTabsChanged(windowId); - }); - - this.on("tab-created", (tab) => { - if (quitController.isQuitting) return; - windowTabsChanged(tab.getWindow().id); - this.reconcilePopupWindow(tab.getWindow().id, { - preferredSpaceId: tab.spaceId, - forcePreferredSpace: true - }); - }); - - this.on("tab-removed", (tab) => { - if (quitController.isQuitting) return; - windowTabsChanged(tab.getWindow().id); - }); - - // When a space is deleted, destroy every tab that still references it. - // Without this, standalone space deletion (e.g. from Settings) leaves - // orphaned tabs with stale spaceId references. - spacesController.on("space-deleted", (_profileId, spaceId) => { - if (quitController.isQuitting) return; - const tabs = this.getTabsInSpace(spaceId); - for (const tab of tabs) { - tab.destroy(); - } - }); - - // Archive/sleep check interval - const interval = setInterval(() => { - for (const tab of this.tabs.values()) { - if (tab.ephemeral) continue; - if (!tab.visible && shouldArchiveTab(tab.lastActiveAt)) { - tab.destroy(); - continue; - } - if (!tab.visible && !tab.asleep && shouldSleepTab(tab.lastActiveAt)) { - const managers = this.getTabManagers(tab.id); - managers?.lifecycle.putToSleep(); - } - } - }, ARCHIVE_CHECK_INTERVAL_MS); - - this.on("destroyed", () => { - clearInterval(interval); - }); - } - - // --- Persistence helper --- - - /** - * Serialize a tab and mark it dirty for persistence. - * Centralises the `serializeTab` + `markDirty` pattern that was previously - * repeated across multiple event handlers. - */ - private persistTab(tab: Tab): void { - if (!shouldPersistTab(tab)) { - tabPersistenceManager.markRemoved(tab.uniqueId); - return; - } - const lifecycleManager = this.tabManagers.get(tab.id)?.lifecycle; - const windowGroupId = `w-${tab.getWindow().id}`; - const serialized = serializeTab(tab, windowGroupId, lifecycleManager?.preSleepState); - tabPersistenceManager.markDirty(tab.uniqueId, serialized); - } - - // --- Manager access --- - - /** - * Get the managers for a tab by tab ID. - */ - public getTabManagers(tabId: number): TabManagers | undefined { - return this.tabManagers.get(tabId); - } - - /** - * Get the lifecycle manager for a tab. - */ - public getLifecycleManager(tabId: number): TabLifecycleManager | undefined { - return this.tabManagers.get(tabId)?.lifecycle; - } - - /** - * Get the layout manager for a tab. - */ - public getLayoutManager(tabId: number): TabLayoutManager | undefined { - return this.tabManagers.get(tabId)?.layout; - } - - // --- Tab Creation --- - - /** - * Create a new tab - */ - public async createTab( - windowId?: number, - profileId?: string, - spaceId?: string, - webContentsViewOptions?: Electron.WebContentsViewConstructorOptions, - tabCreationOptions: Partial = {} - ) { - if (!windowId) { - const focusedWindow = browserWindowsController.getFocusedWindow(); - if (focusedWindow) { - windowId = focusedWindow.id; - } else { - const windows = browserWindowsController.getWindows(); - if (windows.length > 0) { - windowId = windows[0].id; - } else { - throw new Error("Could not determine window ID for new tab"); - } - } - } - - // Get window, and try using profile and space ID from the window first - if (!profileId || !spaceId) { - const window = browserWindowsController.getWindowById(windowId); - if (!window) { - throw new Error("Window not found"); - } - - const windowSpaceId = window.currentSpaceId; - if (windowSpaceId) { - const spaceData = await spacesController.get(windowSpaceId); - const windowProfileId = spaceData?.profileId; - if (windowProfileId && (!profileId || (profileId && profileId === windowProfileId))) { - profileId = windowProfileId; - spaceId = windowSpaceId; - } - } - } - - // Get profile ID and space ID if not provided & window failed to provide - if (!profileId) { - const lastUsedSpace = await spacesController.getLastUsed(); - if (lastUsedSpace) { - profileId = lastUsedSpace.profileId; - spaceId = lastUsedSpace.id; - } else { - throw new Error("Could not determine profile ID for new tab"); - } - } else if (!spaceId) { - try { - const lastUsedSpace = await spacesController.getLastUsedFromProfile(profileId); - if (lastUsedSpace) { - spaceId = lastUsedSpace.id; - } else { - throw new Error("Could not determine space ID for new tab"); - } - } catch (error) { - console.error("Failed to get last used space:", error); - throw new Error("Could not determine space ID for new tab"); - } - } - - // Load profile if not already loaded - await loadedProfilesController.load(profileId); - - // Create tab - return this.internalCreateTab(windowId, profileId, spaceId, webContentsViewOptions, tabCreationOptions); - } - - /** - * Internal method to create a tab. - * Wires up lifecycle, layout, and bounds managers. - */ - public internalCreateTab( - windowId: number, - profileId: string, - spaceId: string, - webContentsViewOptions?: Electron.WebContentsViewConstructorOptions, - tabCreationOptions: Partial = {} - ) { - // Get window - const window = browserWindowsController.getWindowById(windowId); - if (!window) { - throw new Error("Window not found"); - } - - // Get loaded profile - const profile = loadedProfilesController.get(profileId); - if (!profile) { - throw new Error("Profile not found"); - } - - const profileSession = profile.session; - - // Create tab - const tab = new Tab( - { - tabsController: this, - profileId: profileId, - spaceId: spaceId, - session: profileSession, - loadedProfile: profile - }, - { - window: window, - webContentsViewOptions, - ...tabCreationOptions - } - ); - - // --- Wire up managers --- - const lifecycleManager = new TabLifecycleManager(tab); - const boundsController = new TabBoundsController(tab); - const layoutManager = new TabLayoutManager(tab, this, boundsController, lifecycleManager); - - this.tabManagers.set(tab.id, { - lifecycle: lifecycleManager, - layout: layoutManager, - bounds: boundsController - }); - - // Setup fullscreen listeners via lifecycle manager (only for awake tabs) - if (!tabCreationOptions.asleep) { - lifecycleManager.setupFullScreenListeners(window); - } - - this.tabs.set(tab.id, tab); - - // --- Handle deferred initialization --- - - // Handle initial sleep — set pre-sleep state directly on the lifecycle manager - if (tabCreationOptions.asleep) { - const { navHistory, navHistoryIndex } = tabCreationOptions; - if (navHistory && navHistory.length > 0) { - lifecycleManager.preSleepState = { - url: navHistory[navHistoryIndex ?? navHistory.length - 1]?.url ?? "", - navHistory: [...navHistory], - navHistoryIndex: navHistoryIndex ?? navHistory.length - 1 - }; - } - } - - // --- Setup event listeners --- - tab.on("updated", (properties) => { - // During quit, the database is already closed — skip all persistence - // and IPC. WebContents teardown fires navigation/load events that - // propagate here, and accessing the closed DB would crash. - if (quitController.isQuitting) return; - - // When the tab's view is destroyed (sleep), reset cached view state - // so that bounds and border radius are re-applied to the new view on wake. - if (properties.includes("asleep") && tab.asleep) { - layoutManager.onViewDestroyed(); - } - - // Content-only changes (title, url, isLoading, etc.) use the - // lightweight content-changed path which only serializes THIS tab - // instead of all tabs in the window. - windowTabContentChanged(tab.getWindow().id, tab.id); - - // Mark tab dirty for persistence - this.persistTab(tab); - }); - tab.on("space-changed", () => { - if (quitController.isQuitting) return; - - // Structural change — needs full data refresh (tab moved between spaces) - windowTabsChanged(tab.getWindow().id); - this.reconcilePopupWindow(tab.getWindow().id, { - preferredSpaceId: tab.spaceId, - forcePreferredSpace: true - }); - - // Mark tab dirty for persistence - this.persistTab(tab); - }); - tab.on("window-changed", (oldWindowId) => { - if (quitController.isQuitting) return; - - // Structural change — refresh both old window (tab removed) and new window (tab added) - const newWindowId = tab.getWindow().id; - windowTabsChanged(newWindowId); - if (oldWindowId !== newWindowId) { - windowTabsChanged(oldWindowId); - } - this.reconcilePopupWindow(newWindowId, { - preferredSpaceId: tab.spaceId, - forcePreferredSpace: true - }); - if (oldWindowId !== newWindowId) { - this.reconcilePopupWindow(oldWindowId); - } - - // Mark tab dirty for persistence - this.persistTab(tab); - }); - tab.on("focused", () => { - if (this.isTabActive(tab)) { - this.setFocusedTab(tab); - } - }); - - // Handle fullscreen changes — update layout - tab.on("fullscreen-changed", () => { - layoutManager.updateLayout(); - }); - - // Handle new-tab-requested — replaces old Tab.createNewTab() - tab.on("new-tab-requested", (url, disposition, constructorOptions, handlerDetails, options) => { - this.handleNewTabRequested(tab, url, disposition, constructorOptions, handlerDetails, options); - }); - - tab.on("destroyed", () => { - // Cleanup lifecycle - lifecycleManager.onDestroy(); - boundsController.destroy(); - clearPlaceholdersForTab(tab.id); - - // During quit, skip all persistence and tab management — the database - // is closed and windows are being torn down. Accessing them would crash. - if (quitController.isQuitting) { - this.tabManagers.delete(tab.id); - this.tabs.delete(tab.id); - return; - } - - // Add to recently closed and remove from persistence (skip for ephemeral tabs/profiles) - if (shouldPersistTab(tab)) { - const windowGroupId = `w-${tab.getWindow().id}`; - const serialized = serializeTab(tab, windowGroupId, lifecycleManager.preSleepState); - const group = this.getTabGroupByTabId(tab.id); - const groupData = group ? serializeTabGroup(group) : undefined; - recentlyClosedManager.add(serialized, groupData); - - // Remove from persistence - tabPersistenceManager.markRemoved(tab.uniqueId); - } - - // Remove managers - this.tabManagers.delete(tab.id); - - // Remove tab from controller - this.removeTab(tab); - }); - - // --- Initial persistence --- - this.persistTab(tab); - - // --- Initial URL load --- - // Called synchronously after all listeners are wired, so navigation events - // are never missed. No setImmediate needed — webContents.loadURL() is async - // and its events fire in future turns. Placing this call here (before - // createWindow returns for window.open tabs) also ensures the navigation is - // already in flight when the opener calls popup.document.write(): the - // implicit document.open() in document.write cancels the pending navigation - // rather than the other way around. - if (tab._needsInitialLoad && tabCreationOptions.noLoadURL !== true) { - const initialURL = tabCreationOptions.url || tab.loadedProfile.newTabUrl || NEW_TAB_URL; - if (tabCreationOptions.typedNavigation) { - tab.markTypedNavigationForNextHistoryVisit(initialURL); - } - tab.loadURL(initialURL); - } - - // Return tab - this.emit("tab-created", tab); - return tab; - } - - /** - * Handles the "new-tab-requested" event from a tab. - * This replaces the old Tab.createNewTab() method. - */ - private handleNewTabRequested( - sourceTab: Tab, - url: string, - disposition: "new-window" | "foreground-tab" | "background-tab" | "default" | "other", - constructorOptions: Electron.WebContentsViewConstructorOptions | undefined, - handlerDetails: Electron.HandlerDetails | undefined, - options: { noLoadURL?: boolean } - ) { - let windowId = sourceTab.getWindow().id; - const shouldInsertAfterSource = disposition !== "new-window"; - - if (disposition === "new-window") { - const parsedFeatures: Record = {}; - if (handlerDetails?.features) { - const features = handlerDetails.features.split(","); - for (const feature of features) { - const [key, value] = feature.trim().split("="); - if (key && value) { - parsedFeatures[key] = Number.isNaN(+value) ? value : +value; - } - } - } - - const popupWindow = browserWindowsController.instantCreate("popup", { - ...(parsedFeatures.width ? { width: +parsedFeatures.width } : {}), - ...(parsedFeatures.height ? { height: +parsedFeatures.height } : {}), - ...(parsedFeatures.left ? { x: +parsedFeatures.left } : {}), - ...(parsedFeatures.top ? { y: +parsedFeatures.top } : {}) - }); - windowId = popupWindow.id; - - // Keep popup in the same space as the source tab - setWindowSpace(popupWindow, sourceTab.spaceId); - } - - const newTab = this.internalCreateTab(windowId, sourceTab.profileId, sourceTab.spaceId, constructorOptions, { - url, - noLoadURL: options.noLoadURL, - // Tabs opened from an existing tab should appear directly under the opener - // in the sidebar instead of using the default prepend-to-top behavior. - // TODO(Topbar): Should be -0.5 for topbar if we implement topbar. - ...(shouldInsertAfterSource ? { position: sourceTab.position + 0.5 } : {}) - }); - - if (shouldInsertAfterSource) { - this.normalizePositions(sourceTab.getWindow().id, sourceTab.spaceId); - } - - // Set the webContents reference so the createWindow callback can return it - sourceTab._lastCreatedWebContents = newTab.webContents; - - // Handle Glance tab groups if enabled - if (FLAGS.GLANCE_ENABLED && disposition === "foreground-tab") { - const existingGroup = this.getTabGroupByTabId(sourceTab.id); - if (existingGroup && existingGroup.mode === "glance") { - // Add the new tab to the existing glance group - existingGroup.addTab(newTab.id); - existingGroup.setFrontTab(newTab.id); - this.activateTab(existingGroup); - } else { - // Create a new glance group with the source tab and new tab - const glanceGroup = this.createTabGroup("glance", [sourceTab.id, newTab.id]); - if (glanceGroup.mode === "glance") { - glanceGroup.setFrontTab(newTab.id); - } - this.activateTab(glanceGroup); - } - } else if (disposition === "foreground-tab" || disposition === "new-window") { - this.activateTab(newTab); - } - - // Keep source window in the same space for non-popup tab opens - if (disposition !== "new-window") { - setWindowSpace(sourceTab.getWindow(), sourceTab.spaceId); - } - } - - /** - * Disable Picture in Picture mode for a tab - */ - public disablePictureInPicture(tabId: number, goBackToTab: boolean) { - const tab = this.getTabById(tabId); - if (tab && tab.isPictureInPicture) { - tab.updateStateProperty("isPictureInPicture", false); - - if (goBackToTab) { - // Set the space for the window - const win = tab.getWindow(); - setWindowSpace(win, tab.spaceId); - - // Focus window - win.browserWindow.focus(); - - // Set active tab - this.activateTab(tab); - } - - return true; - } - return false; - } - - // --- Active Tab Management --- - - /** - * Process an active tab change — show/hide tabs and update layouts. - */ - private processActiveTabChange(windowId: number, spaceId: string) { - const tabsInWindow = this.getTabsInWindow(windowId); - for (const tab of tabsInWindow) { - const managers = this.getTabManagers(tab.id); - if (!managers) continue; - - if (tab.spaceId === spaceId) { - const isActive = this.isTabActive(tab); - if (isActive && !tab.visible) { - managers.layout.show(); - } else if (!isActive && tab.visible) { - // Exit fullscreen if the tab is no longer active - if (tab.fullScreen) { - managers.lifecycle.setFullScreen(false); - } - managers.layout.hide(); - } else { - // Update layout even if visibility hasn't changed, e.g., for split view resizing - managers.layout.updateLayout(); - } - } else { - // Not in active space — also exit fullscreen if needed - if (tab.fullScreen) { - managers.lifecycle.setFullScreen(false); - } - managers.layout.hide(); - } - } - } - - public isTabActive(tab: Tab) { - const windowSpaceReference = `${tab.getWindow().id}-${tab.spaceId}` as WindowSpaceReference; - const activeTabOrGroup = this.spaceActiveTabMap.get(windowSpaceReference); - - if (!activeTabOrGroup) { - return false; - } - - if (activeTabOrGroup instanceof Tab) { - // Active item is a Tab - return tab.id === activeTabOrGroup.id; - } else { - // Active item is a Tab Group - return activeTabOrGroup.hasTab(tab.id); - } - } - - /** - * Activate a tab/group in its window, applying popup window policy first. - */ - public activateTab(tabOrGroup: Tab | TabGroup) { - const { window, spaceId } = this.getActivationContext(tabOrGroup); - - if (window && !window.destroyed && window.browserWindowType === "popup" && window.currentSpaceId !== spaceId) { - setWindowSpace(window, spaceId); - } - - this.setActiveTab(tabOrGroup); - } - - private indexOfActiveInOrderedList(windowId: number, spaceId: string, ordered: (Tab | TabGroup)[]): number { - const active = this.getActiveTab(windowId, spaceId); - if (!active) return -1; - return ordered.findIndex((item) => { - if (active instanceof Tab) { - return item instanceof Tab && item.id === active.id; - } - return !(item instanceof Tab) && item.groupId === active.groupId; - }); - } - - private activateAdjacentTabInSpace(windowId: number, spaceId: string, delta: 1 | -1): void { - const ordered = this.getOrderedTabOrGroups(windowId, spaceId); - if (ordered.length <= 1) return; - - const idx = this.indexOfActiveInOrderedList(windowId, spaceId, ordered); - if (idx === -1) { - this.activateTab(ordered[0]); - return; - } - - const nextIdx = (idx + delta + ordered.length) % ordered.length; - this.activateTab(ordered[nextIdx]); - } - - /** - * Activate the next tab or group in visual order for a space (wraps). - */ - public activateNextTabInSpace(windowId: number, spaceId: string): void { - this.activateAdjacentTabInSpace(windowId, spaceId, 1); - } - - /** - * Activate the previous tab or group in visual order for a space (wraps). - */ - public activatePreviousTabInSpace(windowId: number, spaceId: string): void { - this.activateAdjacentTabInSpace(windowId, spaceId, -1); - } - - private getActivationContext(tabOrGroup: Tab | TabGroup) { - let windowId: number; - let spaceId: string; - let tabToFocus: Tab | undefined; - let idToStore: number | string; - let window: ReturnType | undefined; - - if (tabOrGroup instanceof Tab) { - windowId = tabOrGroup.getWindow().id; - spaceId = tabOrGroup.spaceId; - tabToFocus = tabOrGroup; - idToStore = tabOrGroup.id; - window = tabOrGroup.getWindow(); - } else { - windowId = tabOrGroup.windowId; - spaceId = tabOrGroup.spaceId; - tabToFocus = tabOrGroup.tabs.length > 0 ? tabOrGroup.tabs[0] : undefined; - idToStore = tabOrGroup.groupId; - window = browserWindowsController.getWindowById(windowId); - } - - return { - windowId, - spaceId, - tabToFocus, - idToStore, - window - }; - } - - /** - * Set the active tab for a space. - * This only updates controller state; callers that need popup-space syncing - * should go through `activateTab()`. - */ - private setActiveTab(tabOrGroup: Tab | TabGroup) { - const { windowId, spaceId, tabToFocus, idToStore } = this.getActivationContext(tabOrGroup); - - const windowSpaceReference = `${windowId}-${spaceId}` as WindowSpaceReference; - this.spaceActiveTabMap.set(windowSpaceReference, tabOrGroup); - - // Update activation history - const history = this.spaceActivationHistory.get(windowSpaceReference) ?? []; - const existingIndex = history.indexOf(idToStore); - if (existingIndex > -1) { - history.splice(existingIndex, 1); - } - history.push(idToStore); - this.spaceActivationHistory.set(windowSpaceReference, history); - - if (tabToFocus) { - this.setFocusedTab(tabToFocus); - } else { - // If group has no tabs, remove focus - this.removeFocusedTab(windowId, spaceId); - } - - this.flushBrowsingHistoryForActivatedTabOrGroup(tabOrGroup); - - this.emit("active-tab-changed", windowId, spaceId); - } - - private flushBrowsingHistoryForActivatedTabOrGroup(tabOrGroup: Tab | TabGroup): void { - if (tabOrGroup instanceof Tab) { - tabOrGroup.recordBrowsingHistoryOnActivationIfNeeded(); - } else { - for (const t of tabOrGroup.tabs) { - t.recordBrowsingHistoryOnActivationIfNeeded(); - } - } - } - - /** - * Get the active tab or group for a space - */ - public getActiveTab(windowId: number, spaceId: string): Tab | TabGroup | undefined { - const windowSpaceReference = `${windowId}-${spaceId}` as WindowSpaceReference; - return this.spaceActiveTabMap.get(windowSpaceReference); - } - - /** - * Remove the active tab for a space and set a new one if possible - */ - public removeActiveTab(windowId: number, spaceId: string, closedPosition?: number) { - const windowSpaceReference = `${windowId}-${spaceId}` as WindowSpaceReference; - this.spaceActiveTabMap.delete(windowSpaceReference); - this.removeFocusedTab(windowId, spaceId); - - // Try finding next active from history - const history = this.spaceActivationHistory.get(windowSpaceReference); - if (history) { - // Iterate backwards through history (most recent first) - for (let i = history.length - 1; i >= 0; i--) { - const itemId = history[i]; - if (typeof itemId === "number") { - // Check if it's an existing Tab - const tab = this.getTabById(itemId); - if (tab && !tab.isDestroyed && tab.getWindow().id === windowId && tab.spaceId === spaceId) { - // Closing should only honor activation history once; after restoring, - // subsequent closes should fall back to visual tab order. - this.spaceActivationHistory.set(windowSpaceReference, [tab.id]); - this.activateTab(tab); - return; - } - } else { - // String — check if it's an existing TabGroup - const group = this.getTabGroupById(itemId); - if ( - group && - !group.isDestroyed && - group.tabs.length > 0 && - group.windowId === windowId && - group.spaceId === spaceId - ) { - // Closing should only honor activation history once; after restoring, - // subsequent closes should fall back to visual tab order. - this.spaceActivationHistory.set(windowSpaceReference, [group.groupId]); - this.activateTab(group); - return; - } - } - } - } - - // Fall back to the next item in tab order. - const nextTabOrGroup = this.getNextTabOrGroupByOrder(windowId, spaceId, closedPosition); - if (nextTabOrGroup) { - this.activateTab(nextTabOrGroup); - } else { - // No valid tabs or groups left - this.emit("active-tab-changed", windowId, spaceId); - } - } - - /** - * Set the focused tab for a space - */ - private setFocusedTab(tab: Tab) { - for (const [key, focusedTab] of this.spaceFocusedTabMap.entries()) { - if (focusedTab.id === tab.id) { - this.spaceFocusedTabMap.delete(key); - } - } - - const windowSpaceReference = `${tab.getWindow().id}-${tab.spaceId}` as WindowSpaceReference; - this.spaceFocusedTabMap.set(windowSpaceReference, tab); - // Only focus the webContents if the tab's window is the currently active OS - // window. Calling webContents.focus() on a background window steals OS - // focus and brings that window to the front — which is the root cause of - // Window A unexpectedly gaining focus when the user switches tabs in - // Window B (the tab relocation makes a background tab visible, Chromium - // emits a focus event, and this call would then pull the OS focus). - if (tab.getWindow().browserWindow.isFocused()) { - tab.webContents?.focus(); - } - } - - /** - * Remove the focused tab for a space - */ - private removeFocusedTab(windowId: number, spaceId: string) { - const windowSpaceReference = `${windowId}-${spaceId}` as WindowSpaceReference; - this.spaceFocusedTabMap.delete(windowSpaceReference); - } - - /** - * Get the focused tab for a space - */ - public getFocusedTab(windowId: number, spaceId: string): Tab | undefined { - const windowSpaceReference = `${windowId}-${spaceId}` as WindowSpaceReference; - return this.spaceFocusedTabMap.get(windowSpaceReference); - } - - /** - * Returns true when the tab is the active tab (or part of the active group) - * in another browser window whose currently visible space matches the tab's - * space. In that case the tab is still effectively on-screen, so auto-PiP - * should not trigger just because this window hid it. - */ - public isTabVisibleInAnotherWindow(tab: Tab): boolean { - for (const window of browserWindowsController.getWindows()) { - if (window.browserWindowType !== "normal" || window.destroyed) continue; - if (window.id === tab.getWindow().id) continue; - if (window.currentSpaceId !== tab.spaceId) continue; - - const activeTabOrGroup = this.getActiveTab(window.id, tab.spaceId); - if (!activeTabOrGroup) continue; - - if (activeTabOrGroup instanceof Tab) { - if (activeTabOrGroup.id === tab.id) { - return true; - } - continue; - } - - if (activeTabOrGroup.hasTab(tab.id)) { - return true; - } - } - - return false; - } - - /** - * Ensure the current active tab/group in a window-space has an actual focused tab. - * Used after sync-driven window moves where the tab view changes windows without - * producing a fresh webContents focus event on its own. - */ - public focusActiveTab(windowId: number, spaceId: string): void { - const activeTabOrGroup = this.getActiveTab(windowId, spaceId); - if (!activeTabOrGroup) { - this.removeFocusedTab(windowId, spaceId); - return; - } - - if (activeTabOrGroup instanceof Tab) { - this.setFocusedTab(activeTabOrGroup); - return; - } - - const currentFocusedTab = this.getFocusedTab(windowId, spaceId); - if (currentFocusedTab && activeTabOrGroup.hasTab(currentFocusedTab.id)) { - this.setFocusedTab(currentFocusedTab); - return; - } - - const nextFocusedTab = activeTabOrGroup.tabs[0]; - if (nextFocusedTab) { - this.setFocusedTab(nextFocusedTab); - } else { - this.removeFocusedTab(windowId, spaceId); - } - } - - // --- Tab Removal --- - - /** - * Remove a tab from the tab manager - */ - public removeTab(tab: Tab) { - const wasActive = this.isTabActive(tab); - const windowId = tab.getWindow().id; - const spaceId = tab.spaceId; - const tabId = tab.id; - - if (!this.tabs.has(tabId)) return; - - this.tabs.delete(tabId); - this.removeFromActivationHistory(tabId); - this.emit("tab-removed", tab); - - if (wasActive) { - // If the removed tab was part of the active element (tab or group) - const activeElement = this.getActiveTab(windowId, spaceId); - if (activeElement instanceof BaseTabGroup) { - // If it was in an active group, the group handles its internal state. - if (this.getFocusedTab(windowId, spaceId)?.id === tab.id) { - const nextFocus = activeElement.tabs.find((t: Tab) => t.id !== tab.id); - if (nextFocus) { - this.setFocusedTab(nextFocus); - } else { - this.removeFocusedTab(windowId, spaceId); - } - } - // Check if group is now empty - if (activeElement && activeElement.tabs.length === 0) { - this.destroyTabGroup(activeElement.groupId); - } - } else { - // If the active element was the tab itself, remove it and find the next active. - this.removeActiveTab(windowId, spaceId, tab.position); - } - } else { - // Tab was not active, just ensure it's removed from any group - const group = this.getTabGroupByTabId(tab.id); - if (group) { - group.removeTab(tab.id); - if (group.tabs.length === 0) { - this.destroyTabGroup(group.groupId); - } - } - } - - this.reconcilePopupWindow(windowId); - } - - // --- Tab Queries --- - - /** - * Get a tab by id - */ - public getTabById(tabId: number): Tab | undefined { - return this.tabs.get(tabId); - } - - /** - * Mark a tab as ephemeral so it will no longer be persisted to the database. - * Also removes any existing persisted data for this tab and notifies the - * renderer to refresh the tab list (so the tab disappears from the sidebar). - */ - public makeTabEphemeral(tabId: number): void { - const tab = this.tabs.get(tabId); - if (!tab || tab.ephemeral) return; - - // Remove from any tab group before marking ephemeral — the pinned tab - // appears in the pin grid, so keeping it in the sidebar group as well - // would show a confusing duplicate. - const group = this.getTabGroupByTabId(tabId); - if (group) { - group.removeTab(tabId); - // Dissolve degenerate groups (e.g. a 2-tab glance loses a member) - if (group.tabs.length < 2) { - this.destroyTabGroup(group.groupId); - } - } - - tab.ephemeral = true; - tabPersistenceManager.markRemoved(tab.uniqueId); - // Trigger a structural change so the renderer drops this tab from the list - windowTabsChanged(tab.getWindow().id); - } - - /** - * Reverse of makeTabEphemeral: mark a tab as persistent so it will be - * persisted to the database again and reappear in the sidebar tab list. - */ - public makeTabPersistent(tabId: number): void { - const tab = this.tabs.get(tabId); - if (!tab || !tab.ephemeral) return; - tab.ephemeral = false; - - // Immediately serialize and mark dirty so it gets persisted on the next flush - this.persistTab(tab); - - // Trigger a structural change so the renderer adds this tab back to the list - windowTabsChanged(tab.getWindow().id); - } - - /** - * Get a tab by webContents - */ - public getTabByWebContents(webContents: WebContents): Tab | undefined { - for (const tab of this.tabs.values()) { - if (tab.webContents === webContents) { - return tab; - } - } - return undefined; - } - - /** - * Get all tabs in a profile - */ - public getTabsInProfile(profileId: string): Tab[] { - const result: Tab[] = []; - for (const tab of this.tabs.values()) { - if (tab.profileId === profileId) { - result.push(tab); - } - } - return result; - } - - /** - * Clear per-tab in-memory history deduping after history rows are deleted. - * This keeps the next same-URL navigation recordable without touching unrelated profiles. - */ - public clearBrowsingHistoryDedupingForProfile(profileId: string, url?: string): void { - for (const tab of this.getTabsInProfile(profileId)) { - tab.clearBrowsingHistoryDeduping(url); - } - } - - /** - * Get all tabs in a space - */ - public getTabsInSpace(spaceId: string): Tab[] { - const result: Tab[] = []; - for (const tab of this.tabs.values()) { - if (tab.spaceId === spaceId) { - result.push(tab); - } - } - return result; - } - - /** - * Get all tabs in a window space - */ - public getTabsInWindowSpace(windowId: number, spaceId: string): Tab[] { - const result: Tab[] = []; - for (const tab of this.tabs.values()) { - if (tab.getWindow().id === windowId && tab.spaceId === spaceId) { - result.push(tab); - } - } - return result; - } - - /** - * Get activatable items in visual order for a window-space. - * Grouped tabs are represented by their group, not as standalone tabs. - */ - private getOrderedTabOrGroups(windowId: number, spaceId: string): (Tab | TabGroup)[] { - const groupsInSpace = this.getTabGroupsInWindow(windowId).filter( - (group) => group.spaceId === spaceId && !group.isDestroyed && group.tabs.length > 0 - ); - const groupedTabIds = new Set(groupsInSpace.flatMap((group) => group.tabs.map((tab) => tab.id))); - const standaloneTabs = this.getTabsInWindowSpace(windowId, spaceId).filter((tab) => !groupedTabIds.has(tab.id)); - - return [...groupsInSpace, ...standaloneTabs].sort((a, b) => a.position - b.position); - } - - /** - * Pick the next activatable tab/group from the closed item's position. - * Prefer the next item in order; if the closed item was last, use the previous one. - */ - private getNextTabOrGroupByOrder( - windowId: number, - spaceId: string, - closedPosition?: number - ): Tab | TabGroup | undefined { - const orderedItems = this.getOrderedTabOrGroups(windowId, spaceId); - if (orderedItems.length === 0) { - return undefined; - } - - if (closedPosition === undefined) { - return orderedItems[0]; - } - - return orderedItems.find((item) => item.position >= closedPosition) ?? orderedItems[orderedItems.length - 1]; - } - - private getPopupTargetForSpace(windowId: number, spaceId: string): Tab | TabGroup | undefined { - const activeTabOrGroup = this.getActiveTab(windowId, spaceId); - if (activeTabOrGroup instanceof Tab) { - if ( - !activeTabOrGroup.isDestroyed && - activeTabOrGroup.getWindow().id === windowId && - activeTabOrGroup.spaceId === spaceId - ) { - return activeTabOrGroup; - } - } else if (activeTabOrGroup) { - if ( - !activeTabOrGroup.isDestroyed && - activeTabOrGroup.windowId === windowId && - activeTabOrGroup.spaceId === spaceId && - activeTabOrGroup.tabs.length > 0 - ) { - return activeTabOrGroup; - } - } - - return this.getNextTabOrGroupByOrder(windowId, spaceId); - } - - private getPopupTargetLastActiveAt(tabOrGroup: Tab | TabGroup): number { - if (tabOrGroup instanceof Tab) { - return tabOrGroup.lastActiveAt; - } - - return tabOrGroup.tabs.reduce((latest, tab) => Math.max(latest, tab.lastActiveAt), 0); - } - - private reconcilePopupWindow(windowId: number, options: PopupWindowReconcileOptions = {}): void { - if (quitController.isQuitting) return; - - const window = browserWindowsController.getWindowById(windowId); - if (!window || window.destroyed || window.browserWindowType !== "popup") { - return; - } - - const tabsInWindow = this.getTabsInWindow(windowId); - if (tabsInWindow.length === 0) { - setImmediate(() => { - const latestWindow = browserWindowsController.getWindowById(windowId); - if (!latestWindow || latestWindow.destroyed || latestWindow.browserWindowType !== "popup") { - return; - } - if (this.getTabsInWindow(windowId).length > 0) { - return; - } - latestWindow.close(); - }); - return; - } - - if (options.forcePreferredSpace && options.preferredSpaceId) { - const preferredTarget = this.getPopupTargetForSpace(windowId, options.preferredSpaceId); - if (preferredTarget) { - this.activateTab(preferredTarget); - return; - } - } - - const currentSpaceId = window.currentSpaceId; - if (currentSpaceId) { - const currentSpaceTarget = this.getPopupTargetForSpace(windowId, currentSpaceId); - if (currentSpaceTarget) { - this.activateTab(currentSpaceTarget); - return; - } - } - - let bestTarget: Tab | TabGroup | undefined; - let bestLastActiveAt = -Infinity; - - const candidateSpaceIds = new Set(tabsInWindow.map((tab) => tab.spaceId)); - for (const candidateSpaceId of candidateSpaceIds) { - const candidateTarget = this.getPopupTargetForSpace(windowId, candidateSpaceId); - if (!candidateTarget) continue; - - const candidateLastActiveAt = this.getPopupTargetLastActiveAt(candidateTarget); - if (!bestTarget || candidateLastActiveAt > bestLastActiveAt) { - bestTarget = candidateTarget; - bestLastActiveAt = candidateLastActiveAt; - } - } - - if (bestTarget) { - this.activateTab(bestTarget); - } - } - - /** - * Get all tabs in a window - */ - public getTabsInWindow(windowId: number): Tab[] { - const result: Tab[] = []; - for (const tab of this.tabs.values()) { - if (tab.getWindow().id === windowId) { - result.push(tab); - } - } - return result; - } - - // --- Tab Group Queries --- - - /** - * Get all tab groups in a window - */ - public getTabGroupsInWindow(windowId: number): TabGroup[] { - const result: TabGroup[] = []; - for (const group of this.tabGroups.values()) { - if (group.windowId === windowId) { - result.push(group); - } - } - return result; - } - - /** - * Get a tab group by tab id - */ - public getTabGroupByTabId(tabId: number): TabGroup | undefined { - const tab = this.getTabById(tabId); - if (tab && tab.groupId !== null) { - return this.tabGroups.get(tab.groupId); - } - return undefined; - } - - /** - * Get a tab group by its string groupId - */ - public getTabGroupById(groupId: string): TabGroup | undefined { - return this.tabGroups.get(groupId); - } - - // --- Tab Group Management --- - - /** - * Create a new tab group - */ - public createTabGroup(mode: TabGroupMode, initialTabIds: [number, ...number[]], preferredGroupId?: string): TabGroup { - let groupId: string; - if (preferredGroupId) { - if (this.tabGroups.has(preferredGroupId)) { - throw new Error(`Tab group ID already exists: ${preferredGroupId}`); - } - - groupId = preferredGroupId; - - const groupIdMatch = /^tg-(\d+)$/.exec(preferredGroupId); - if (groupIdMatch) { - const parsedCounter = Number(groupIdMatch[1]); - if (Number.isFinite(parsedCounter)) { - this.tabGroupCounter = Math.max(this.tabGroupCounter, parsedCounter + 1); - } - } - } else { - do { - groupId = `tg-${this.tabGroupCounter++}`; - } while (this.tabGroups.has(groupId)); - } - - const initialTabs: Tab[] = []; - for (const tabId of initialTabIds) { - const tab = this.getTabById(tabId); - if (tab) { - // Remove tab from any existing group it might be in - const existingGroup = this.getTabGroupByTabId(tabId); - existingGroup?.removeTab(tabId); - initialTabs.push(tab); - } - } - - if (initialTabs.length === 0) { - throw new Error("Cannot create a tab group with no valid initial tabs."); - } - - let tabGroup: TabGroup; - switch (mode) { - case "glance": - tabGroup = new GlanceTabGroup(this, groupId, initialTabs as [Tab, ...Tab[]]); - break; - case "split": - tabGroup = new SplitTabGroup(this, groupId, initialTabs as [Tab, ...Tab[]]); - break; - default: - throw new Error(`Invalid tab group mode: ${mode}`); - } - - tabGroup.on("destroyed", () => { - // Ensure cleanup happens even if destroyTabGroup isn't called externally - if (this.tabGroups.has(groupId)) { - this.internalDestroyTabGroup(tabGroup); - } - }); - - tabGroup.on("changed", () => { - // Skip persistence during quit — the database is already closed - if (quitController.isQuitting) return; - - // Persist tab group state whenever it mutates - tabPersistenceManager - .saveTabGroup(groupId, serializeTabGroup(tabGroup)) - .catch((err) => console.error("[TabsController] Failed to save tab group:", err)); - }); - - this.tabGroups.set(groupId, tabGroup); - - // Persist the tab group - tabPersistenceManager - .saveTabGroup(groupId, serializeTabGroup(tabGroup)) - .catch((err) => console.error("[TabsController] Failed to save tab group:", err)); - - // If any of the initial tabs were active, make the new group active. - const firstTab = initialTabs[0]; - const currentActive = this.getActiveTab(firstTab.getWindow().id, firstTab.spaceId); - const currentActiveIsFirstTab = currentActive instanceof Tab && currentActive.id === firstTab.id; - if (currentActiveIsFirstTab) { - this.activateTab(tabGroup); - } else { - // Ensure layout is updated for grouped tabs - for (const t of tabGroup.tabs) { - const managers = this.getTabManagers(t.id); - managers?.layout.updateLayout(); - } - } - - return tabGroup; - } - - /** - * Get the smallest position of all tabs - */ - public getSmallestPosition(): number { - let smallestPosition = 999; - for (const tab of this.tabs.values()) { - if (tab.position < smallestPosition) { - smallestPosition = tab.position; - } - } - return smallestPosition; - } - - /** - * Internal method to cleanup destroyed tab group state - */ - private internalDestroyTabGroup(tabGroup: TabGroup) { - const wasActive = this.getActiveTab(tabGroup.windowId, tabGroup.spaceId) === tabGroup; - const groupId = tabGroup.groupId; - const groupPosition = tabGroup.getAnchorPosition(); - - if (!this.tabGroups.has(groupId)) return; - - this.tabGroups.delete(groupId); - this.removeFromActivationHistory(groupId); - - // Remove from persistence (skip during quit — DB is closed) - if (!quitController.isQuitting) { - tabPersistenceManager.removeTabGroup(groupId); - } - - if (wasActive) { - this.removeActiveTab(tabGroup.windowId, tabGroup.spaceId, groupPosition); - } - } - - /** - * Destroy a tab group - */ - public destroyTabGroup(groupId: string) { - const tabGroup = this.getTabGroupById(groupId); - if (!tabGroup) { - console.warn(`Attempted to destroy non-existent tab group ID: ${groupId}`); - return; - } - - // Ensure group's destroy logic runs first - if (!tabGroup.isDestroyed) { - tabGroup.destroy(); // This triggers the "destroyed" event - } - - // Cleanup TabsController state (might be redundant if event handler runs, but safe) - this.internalDestroyTabGroup(tabGroup); - } - - // --- Window Space Management --- - - /** - * Set the current space for a window - */ - public setCurrentWindowSpace(windowId: number, spaceId: string) { - this.windowActiveSpaceMap.set(windowId, spaceId); - - this.emit("current-space-changed", windowId, spaceId); - } - - /** - * Handle page bounds changed - */ - public handlePageBoundsChanged(windowId: number) { - const tabsInWindow = this.getTabsInWindow(windowId); - for (const tab of tabsInWindow) { - if (!tab.visible) continue; - const managers = this.getTabManagers(tab.id); - managers?.layout.updateLayout(); - } - } - - // --- Activation History --- - - /** - * Helper method to remove an item ID from all activation history lists. - * Handles both tab IDs (number) and group IDs (string). - */ - private removeFromActivationHistory(itemId: number | string) { - for (const [key, history] of this.spaceActivationHistory.entries()) { - const initialLength = history.length; - const newHistory = history.filter((id) => id !== itemId); - if (newHistory.length < initialLength) { - if (newHistory.length === 0) { - this.spaceActivationHistory.delete(key); - } else { - this.spaceActivationHistory.set(key, newHistory); - } - } - } - } - - /** - * Purge all map entries associated with a given window. - * Called when a window is closed to prevent stale references from - * accumulating in the internal tracking maps. - */ - public cleanupWindowEntries(windowId: number): void { - this.windowActiveSpaceMap.delete(windowId); - - const prefix = `${windowId}-`; - for (const key of this.spaceActiveTabMap.keys()) { - if (key.startsWith(prefix)) this.spaceActiveTabMap.delete(key); - } - for (const key of this.spaceFocusedTabMap.keys()) { - if (key.startsWith(prefix)) this.spaceFocusedTabMap.delete(key); - } - for (const key of this.spaceActivationHistory.keys()) { - if (key.startsWith(prefix)) this.spaceActivationHistory.delete(key); - } - } - - // --- Position Normalization --- - - /** - * Normalize tab positions to prevent drift to negative infinity. - * Called periodically or when positions are getting too extreme. - * - * In sync mode the renderer shows ALL tabs in a space regardless of - * which window owns them, so normalization must cover the full set. - */ - public normalizePositions(windowId: number, spaceId: string) { - let tabs: Tab[]; - if (isTabSyncEnabled()) { - // In sync mode, normalize all tabs in the space but exclude - // internal-profile and popup-window tabs from other windows (they are not synced). - tabs = this.getTabsInSpace(spaceId).filter((tab) => tab.getWindow().id === windowId || !isSyncExcludedTab(tab)); - } else { - tabs = this.getTabsInWindowSpace(windowId, spaceId); - } - if (tabs.length === 0) return; - - // Sort by current position - tabs.sort((a, b) => a.position - b.position); - - // Reassign positions starting from 0 - for (let i = 0; i < tabs.length; i++) { - tabs[i].updateStateProperty("position", i); - } - } -} - -export { type TabsController }; -export const tabsController = new TabsController(); -registerTabsController(tabsController); diff --git a/src/main/controllers/tabs-controller/recently-closed-manager.ts b/src/main/controllers/tabs-controller/recently-closed-manager.ts deleted file mode 100644 index b9ec296ce..000000000 --- a/src/main/controllers/tabs-controller/recently-closed-manager.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { TypedEventEmitter } from "@/modules/typed-event-emitter"; -import { getCurrentTimestamp } from "@/modules/utils"; -import { RecentlyClosedTabData, PersistedTabData, PersistedTabGroupData } from "~/types/tabs"; - -const MAX_RECENTLY_CLOSED = 10; - -type RecentlyClosedEvents = { - changed: []; -}; - -/** - * Runtime-only store for recently closed tabs. - * Closed tabs should never survive an app restart. - */ -export class RecentlyClosedManager extends TypedEventEmitter { - private entries: RecentlyClosedTabData[] = []; - - /** - * Add a tab to the recently closed list. - * Maintains a most-recent-first list capped at MAX_RECENTLY_CLOSED entries. - */ - add(tabData: PersistedTabData, tabGroupData?: PersistedTabGroupData): void { - const closedAt = getCurrentTimestamp(); - this.entries = this.entries.filter((entry) => entry.tabData.uniqueId !== tabData.uniqueId); - this.entries.unshift({ - closedAt, - tabData, - tabGroupData - }); - this.entries.length = Math.min(this.entries.length, MAX_RECENTLY_CLOSED); - this.emit("changed"); - } - - /** - * Get all recently closed tabs, sorted by most recently closed first. - */ - getAll(): RecentlyClosedTabData[] { - return [...this.entries]; - } - - public hasEntries(): boolean { - return this.entries.length > 0; - } - - public peekMostRecent(): RecentlyClosedTabData | null { - return this.entries[0] ?? null; - } - - /** - * Restore a recently closed tab by uniqueId. - * Removes it from the in-memory store and returns the persisted data along - * with any tab group data the tab belonged to. - */ - restore(uniqueId: string): { tabData: PersistedTabData; tabGroupData?: PersistedTabGroupData } | null { - const index = this.entries.findIndex((entry) => entry.tabData.uniqueId === uniqueId); - if (index === -1) return null; - - const [row] = this.entries.splice(index, 1); - this.emit("changed"); - return { - tabData: row.tabData, - tabGroupData: row.tabGroupData - }; - } - - public restoreMostRecent(): { - tabData: PersistedTabData; - tabGroupData?: PersistedTabGroupData; - } | null { - const mostRecent = this.peekMostRecent(); - if (!mostRecent) return null; - return this.restore(mostRecent.tabData.uniqueId); - } - - /** - * Clear all recently closed tabs. - */ - clear(): void { - if (this.entries.length === 0) return; - this.entries = []; - this.emit("changed"); - } -} - -export const recentlyClosedManager = new RecentlyClosedManager(); diff --git a/src/main/controllers/tabs-controller/recently-closed.ts b/src/main/controllers/tabs-controller/recently-closed.ts deleted file mode 100644 index 9a42caf9b..000000000 --- a/src/main/controllers/tabs-controller/recently-closed.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { GlanceTabGroup } from "@/controllers/tabs-controller/tab-groups/glance"; -import { spacesController } from "@/controllers/spaces-controller"; -import { recentlyClosedManager } from "./recently-closed-manager"; -import type { BrowserWindow } from "@/controllers/windows-controller/types"; -import { PersistedTabData, PersistedTabGroupData } from "~/types/tabs"; -import { Tab } from "./tab"; -import { tabsController } from "."; - -/** - * Attempts to restore a tab's group membership after it has been recreated. - * - * If the tab's original group still exists (other members survived), the tab - * is added back to it. Otherwise, if other tabs from the same group are still - * alive (but the group was destroyed), a new group is created with those tabs - * plus the restored tab. If only the restored tab remains, it stays standalone. - */ -export function restoreTabGroupMembership(restoredTab: Tab, groupData?: PersistedTabGroupData): void { - if (!groupData) return; - - const tabsByUniqueId = new Map(); - for (const tab of tabsController.tabs.values()) { - if (!tab.isDestroyed) { - tabsByUniqueId.set(tab.uniqueId, tab); - } - } - - const otherTabIds: number[] = []; - for (const uniqueId of groupData.tabUniqueIds) { - if (uniqueId === restoredTab.uniqueId) continue; - const tab = tabsByUniqueId.get(uniqueId); - if (tab) { - otherTabIds.push(tab.id); - } - } - - if (otherTabIds.length === 0) { - return; - } - - const existingGroup = tabsController.getTabGroupByTabId(otherTabIds[0]); - if (existingGroup && existingGroup.mode === groupData.mode) { - existingGroup.addTab(restoredTab.id); - - if ( - groupData.mode === "glance" && - groupData.glanceFrontTabUniqueId === restoredTab.uniqueId && - existingGroup instanceof GlanceTabGroup - ) { - existingGroup.setFrontTab(restoredTab.id); - } - return; - } - - const allTabIds = [restoredTab.id, ...otherTabIds]; - - try { - const newGroup = tabsController.createTabGroup(groupData.mode, allTabIds as [number, ...number[]]); - - if (groupData.mode === "glance" && groupData.glanceFrontTabUniqueId) { - const frontTab = tabsByUniqueId.get(groupData.glanceFrontTabUniqueId); - if (frontTab && newGroup instanceof GlanceTabGroup) { - newGroup.setFrontTab(frontTab.id); - } - } - } catch (error) { - console.error("Failed to restore tab group membership:", error); - } -} - -async function restoreIntoWindow( - window: BrowserWindow, - result: { tabData: PersistedTabData; tabGroupData?: PersistedTabGroupData } -): Promise { - const { tabData, tabGroupData } = result; - const space = await spacesController.get(tabData.spaceId); - if (!space) return false; - - const restoredTab = await tabsController.createTab(window.id, space.profileId, tabData.spaceId, undefined, { - uniqueId: tabData.uniqueId, - window, - createdAt: tabData.createdAt, - lastActiveAt: tabData.lastActiveAt, - position: tabData.position, - title: tabData.title, - faviconURL: tabData.faviconURL ?? undefined, - navHistory: tabData.navHistory, - navHistoryIndex: tabData.navHistoryIndex - }); - - restoreTabGroupMembership(restoredTab, tabGroupData); - tabsController.activateTab(restoredTab); - return true; -} - -export async function restoreRecentlyClosedTabInWindow(window: BrowserWindow, uniqueId: string): Promise { - const result = recentlyClosedManager.restore(uniqueId); - if (!result) return false; - return restoreIntoWindow(window, result); -} - -export async function restoreMostRecentClosedTabInWindow(window: BrowserWindow): Promise { - const result = recentlyClosedManager.restoreMostRecent(); - if (!result) return false; - return restoreIntoWindow(window, result); -} diff --git a/src/main/controllers/tabs-controller/tab-groups/glance.ts b/src/main/controllers/tabs-controller/tab-groups/glance.ts deleted file mode 100644 index 5f3ee234b..000000000 --- a/src/main/controllers/tabs-controller/tab-groups/glance.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { BaseTabGroup } from "./index"; - -export class GlanceTabGroup extends BaseTabGroup { - public frontTabId: number = -1; - public mode: "glance" = "glance" as const; - - constructor(...args: ConstructorParameters) { - super(...args); - - this.on("tab-removed", () => { - if (this.tabIds.length !== 2) { - // A glance tab group must have 2 tabs - this.destroy(); - } - }); - } - - public setFrontTab(tabId: number) { - this.frontTabId = tabId; - this.emit("changed"); - } -} diff --git a/src/main/controllers/tabs-controller/tab-groups/index.ts b/src/main/controllers/tabs-controller/tab-groups/index.ts deleted file mode 100644 index 419c4b6c3..000000000 --- a/src/main/controllers/tabs-controller/tab-groups/index.ts +++ /dev/null @@ -1,249 +0,0 @@ -import { browserWindowsController } from "@/controllers/windows-controller/interfaces/browser"; -import { Tab } from "../tab"; -import { TypedEventEmitter } from "@/modules/typed-event-emitter"; -import { type GlanceTabGroup } from "./glance"; -import { type SplitTabGroup } from "./split"; -import { type TabsController } from "@/controllers/tabs-controller"; - -// Interfaces and Types -export type TabGroupEvents = { - "tab-added": [number]; - "tab-removed": [number]; - "space-changed": []; - "window-changed": []; - changed: []; - destroyed: []; -}; - -function getTabFromId(tabsController: TabsController, id: number): Tab | null { - const tab = tabsController.getTabById(id); - if (!tab) { - return null; - } - return tab; -} - -// Tab Group Class -export type TabGroup = GlanceTabGroup | SplitTabGroup; - -export class BaseTabGroup extends TypedEventEmitter { - /** String identifier used as map key, persistence key, and Tab.groupId value */ - public readonly groupId: string; - public isDestroyed: boolean = false; - - public windowId: number; - public profileId: string; - public spaceId: string; - - protected tabsController: TabsController; - protected tabIds: number[] = []; - - constructor(tabsController: TabsController, groupId: string, initialTabs: [Tab, ...Tab[]]) { - super(); - - this.tabsController = tabsController; - this.groupId = groupId; - - const initialTab = initialTabs[0]; - - this.windowId = initialTab.getWindow().id; - this.profileId = initialTab.profileId; - this.spaceId = initialTab.spaceId; - - for (const tab of initialTabs) { - this.addTab(tab.id); - } - - // Change space of all tabs in the group - this.on("space-changed", () => { - for (const tab of this.tabs) { - if (tab.spaceId !== this.spaceId) { - tab.setSpace(this.spaceId); - } - } - }); - } - - public setSpace(spaceId: string) { - this.errorIfDestroyed(); - - this.spaceId = spaceId; - this.emit("space-changed"); - this.emit("changed"); - - for (const tab of this.tabs) { - this.syncTab(tab); - } - } - - public setWindow(windowId: number) { - this.errorIfDestroyed(); - - this.windowId = windowId; - this.emit("window-changed"); - this.emit("changed"); - - for (const tab of this.tabs) { - this.syncTab(tab); - } - } - - public syncTab(tab: Tab) { - this.errorIfDestroyed(); - - tab.setSpace(this.spaceId); - - const window = browserWindowsController.getWindowById(this.windowId); - if (window) { - tab.setWindow(window); - } - } - - protected errorIfDestroyed() { - if (this.isDestroyed) { - throw new Error("TabGroup already destroyed!"); - } - } - - public hasTab(tabId: number): boolean { - this.errorIfDestroyed(); - - return this.tabIds.includes(tabId); - } - - public addTab(tabId: number) { - this.errorIfDestroyed(); - - if (this.hasTab(tabId)) { - return false; - } - - const tab = getTabFromId(this.tabsController, tabId); - if (tab === null) { - return false; - } - - tab.groupId = this.groupId; - - this.tabIds.push(tabId); - this.emit("tab-added", tabId); - this.emit("changed"); - - // Event Listeners - const onTabDestroyed = () => { - this.removeTab(tabId); - }; - const onTabRemoved = (tabId: number) => { - if (tabId === tab.id) { - disconnectAll(); - } - }; - const onTabSpaceChanged = () => { - const newSpaceId = tab.spaceId; - if (newSpaceId !== this.spaceId) { - this.setSpace(newSpaceId); - } - }; - const onTabWindowChanged = () => { - const newWindowId = tab.getWindow()?.id; - if (newWindowId !== this.windowId) { - this.setWindow(newWindowId); - } - }; - const onActiveTabChanged = (windowId: number, spaceId: string) => { - if (windowId === this.windowId && spaceId === this.spaceId) { - const activeTab = this.tabsController.getActiveTab(windowId, spaceId); - if (activeTab === tab) { - // Set this tab group as active instead of just the tab - // @ts-expect-error: the base class won't be used directly anyways - this.tabsController.activateTab(this); - } - } - }; - const onDestroy = () => { - disconnectAll(); - }; - - const disconnectAll = () => { - disconnect1(); - disconnect2(); - disconnect3(); - disconnect4(); - disconnect5(); - disconnect6(); - }; - const disconnect1 = tab.connect("destroyed", onTabDestroyed); - const disconnect2 = this.connect("tab-removed", onTabRemoved); - const disconnect3 = tab.connect("space-changed", onTabSpaceChanged); - const disconnect4 = tab.connect("window-changed", onTabWindowChanged); - const disconnect5 = this.tabsController.connect("active-tab-changed", onActiveTabChanged); - const disconnect6 = this.connect("destroyed", onDestroy); - - // Sync tab space and window - this.syncTab(tab); - return true; - } - - public removeTab(tabId: number) { - this.errorIfDestroyed(); - - if (!this.hasTab(tabId)) { - return false; - } - - // Clear the groupId on the tab being removed - const tab = getTabFromId(this.tabsController, tabId); - if (tab && tab.groupId === this.groupId) { - tab.groupId = null; - } - - this.tabIds = this.tabIds.filter((id) => id !== tabId); - this.emit("tab-removed", tabId); - this.emit("changed"); - return true; - } - - public get tabs(): Tab[] { - this.errorIfDestroyed(); - - const tabsController = this.tabsController; - return this.tabIds - .map((id) => { - return getTabFromId(tabsController, id); - }) - .filter((tab) => tab !== null); - } - - public get position(): number { - this.errorIfDestroyed(); - return this.tabs[0].position; - } - - /** - * Best-effort anchor position for close-order fallback. - * Unlike `position`, this remains readable during destroy cleanup. - */ - public getAnchorPosition(): number | undefined { - const firstTabId = this.tabIds[0]; - if (firstTabId === undefined) { - return undefined; - } - - return getTabFromId(this.tabsController, firstTabId)?.position; - } - - public destroy() { - this.errorIfDestroyed(); - - // Clear groupId for all tabs in the group before destroying - for (const tab of this.tabs) { - if (tab.groupId === this.groupId) { - tab.groupId = null; - } - } - - this.isDestroyed = true; - this.emit("destroyed"); - this.destroyEmitter(); - } -} diff --git a/src/main/controllers/tabs-controller/tab-groups/split.ts b/src/main/controllers/tabs-controller/tab-groups/split.ts deleted file mode 100644 index 23c326b44..000000000 --- a/src/main/controllers/tabs-controller/tab-groups/split.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { BaseTabGroup } from "./index"; - -export class SplitTabGroup extends BaseTabGroup { - public mode: "split" = "split" as const; - - // TODO: Implement split tab group layout -} diff --git a/src/main/controllers/tabs-controller/tab-layout.ts b/src/main/controllers/tabs-controller/tab-layout.ts deleted file mode 100644 index c1a7e291a..000000000 --- a/src/main/controllers/tabs-controller/tab-layout.ts +++ /dev/null @@ -1,213 +0,0 @@ -import { Tab } from "./tab"; -import { TabBoundsController, isRectangleEqual } from "./bounds"; -import { TabLifecycleManager } from "./tab-lifecycle"; -import { getCurrentTimestamp } from "@/modules/utils"; -import { TabGroupMode } from "~/types/tabs"; -import { type LayerType } from "~/layers"; -import { Rectangle } from "electron"; -import { type TabsController } from "./index"; - -/** - * Manages tab layout: bounds calculation, visibility, z-index positioning. - * - * Design notes: - * - Reads tab state but only mutates it through tab.updateStateProperty() - * - Uses TabBoundsController for spring-physics bounds animation - * - Needs a reference to TabsController to query tab group membership - * (one-way dependency: layout -> controller, never controller -> layout) - * - Needs a reference to TabLifecycleManager for wake-on-show and PiP transitions - */ -export class TabLayoutManager { - private lastTabGroupMode: TabGroupMode | null = null; - private lastBorderRadius: number | null = null; - - constructor( - private readonly tab: Tab, - private readonly tabsController: TabsController, - private readonly boundsController: TabBoundsController, - private readonly lifecycleManager: TabLifecycleManager - ) {} - - /** - * Resets cached view state (bounds, border radius) when the underlying - * WebContentsView is destroyed (e.g. on sleep). This ensures the next - * updateLayout() call will re-apply bounds and border radius to the - * newly created view instead of skipping due to stale equality checks. - */ - onViewDestroyed(): void { - this.boundsController.resetLastAppliedBounds(); - this.lastBorderRadius = null; - } - - /** - * Resets cached layout state when a tab moves to a different window. - * - * The new window likely has different pageBounds. Without this reset the - * TabBoundsController's `lastAppliedBounds` still holds the old window's - * values, and if the two windows happen to share the same dimensions (or - * close enough after rounding) `updateViewBounds()` would skip applying - * the new bounds entirely — causing the tab to render with stale bounds - * or not appear at all. - */ - onWindowChanged(): void { - this.boundsController.resetLastAppliedBounds(); - this.lastBorderRadius = null; - } - - /** - * Shows the tab (sets visible = true and updates layout). - */ - show(): void { - const updated = this.tab.updateStateProperty("visible", true); - if (!updated) return; // Already visible - this.updateLayout(); - } - - /** - * Hides the tab (sets visible = false and updates layout). - */ - hide(): void { - const updated = this.tab.updateStateProperty("visible", false); - if (!updated) return; // Already hidden - this.updateLayout(); - } - - /** - * Full layout update for the tab. Handles: - * - Visibility sync with the WebContentsView - * - PiP enter/exit on visibility transitions - * - Wake-on-show for sleeping tabs - * - Bounds calculation based on tab group mode (normal/glance/split) - * - Z-index management - * - Spring-animated bounds transitions - */ - updateLayout(): void { - const { tab, tabsController, boundsController } = this; - const { visible } = tab; - const window = tab.getWindow(); - - // Sync view visibility (only if view exists — sleeping tabs have no view) - const wasVisible = tab.layer ? tab.layer.isVisible() : false; - if (tab.layer && wasVisible !== visible) { - tab.layer.setVisible(visible); - - // Handle PiP transitions on visibility change - if (visible) { - this.lifecycleManager.exitPictureInPicture(); - } else { - // Only enter PiP if no other tab is already in PiP. Without this guard, - // restoring a PiP tab hides the previously-active tab, which then tries - // to enter PiP, creating a loop where each tab's PiP exit triggers the - // other to enter PiP indefinitely. - const windowId = tab.getWindow().id; - const anyTabInPiP = this.tabsController - .getTabsInWindow(windowId) - .some((t) => t.id !== tab.id && t.isPictureInPicture); - const isStillVisibleElsewhere = this.tabsController.isTabVisibleInAnotherWindow(tab); - if (!anyTabInPiP && !isStillVisibleElsewhere) { - this.lifecycleManager.enterPictureInPicture(); - } - } - } - - // Update lastActiveAt on visibility transitions - const justHidden = wasVisible && !visible; - const justShown = !wasVisible && visible; - if (justHidden || justShown) { - tab.updateStateProperty("lastActiveAt", getCurrentTimestamp()); - } - - if (!visible) return; - - // Update extensions on show - if (justShown && tab.webContents) { - const extensions = tab.loadedProfile.extensions; - extensions.selectTab(tab.webContents); - } - - // Auto-wake sleeping tabs when they become visible - this.lifecycleManager.wakeUp(); - - // Get base bounds and fullscreen state. - // In fullscreen, bypass the renderer-reported pageBounds and use the - // full window content area directly. This eliminates the timing gap - // between entering fullscreen and the renderer remeasuring/reporting - // new bounds — the tab fills the window immediately. - let pageBounds: Rectangle; - if (tab.fullScreen) { - const [contentWidth, contentHeight] = window.browserWindow.getContentSize(); - pageBounds = { x: 0, y: 0, width: contentWidth, height: contentHeight }; - } else { - pageBounds = window.pageBounds; - } - const borderRadius = tab.fullScreen ? 0 : 6; - if (borderRadius !== this.lastBorderRadius && tab.view) { - tab.view.setBorderRadius(borderRadius); - this.lastBorderRadius = borderRadius; - } - - // Determine tab group mode and calculate bounds - const tabGroup = tabsController.getTabGroupByTabId(tab.id); - const lastTabGroupMode = this.lastTabGroupMode; - let newBounds: Rectangle | null = null; - let newTabGroupMode: TabGroupMode | null = null; - let layerType: LayerType = "tab"; - - if (!tabGroup) { - newTabGroupMode = "normal"; - newBounds = pageBounds; - } else if (tabGroup.mode === "glance") { - newTabGroupMode = "glance"; - const isFront = tabGroup.frontTabId === tab.id; - newBounds = this.calculateGlanceBounds(pageBounds, isFront); - - layerType = isFront ? "tab" : "tabBack"; - } else if (tabGroup.mode === "split") { - newTabGroupMode = "split"; - // TODO: Implement split tab group layout - } - - // Update z-index via setWindow - tab.setWindow(window, layerType); - - // Track mode changes - if (newTabGroupMode !== lastTabGroupMode) { - this.lastTabGroupMode = newTabGroupMode; - } - - // Apply calculated bounds with spring animation - if (newBounds) { - const useImmediateUpdate = - newTabGroupMode === lastTabGroupMode && - isRectangleEqual(boundsController.bounds, boundsController.targetBounds); - - if (useImmediateUpdate) { - boundsController.setBoundsImmediate(newBounds); - } else { - boundsController.setBounds(newBounds); - } - } - } - - /** - * Calculates bounds for a tab in glance mode. - * Front tab is slightly smaller; back tab is larger but behind. - */ - private calculateGlanceBounds(pageBounds: Rectangle, isFront: boolean): Rectangle { - const widthPercentage = isFront ? 0.85 : 0.95; - const heightPercentage = isFront ? 1 : 0.975; - - const newWidth = Math.floor(pageBounds.width * widthPercentage); - const newHeight = Math.floor(pageBounds.height * heightPercentage); - - const xOffset = Math.floor((pageBounds.width - newWidth) / 2); - const yOffset = Math.floor((pageBounds.height - newHeight) / 2); - - return { - x: pageBounds.x + xOffset, - y: pageBounds.y + yOffset, - width: newWidth, - height: newHeight - }; - } -} diff --git a/src/main/controllers/tabs-controller/tab-lifecycle.ts b/src/main/controllers/tabs-controller/tab-lifecycle.ts deleted file mode 100644 index 7b69a7cf4..000000000 --- a/src/main/controllers/tabs-controller/tab-lifecycle.ts +++ /dev/null @@ -1,307 +0,0 @@ -import { Tab } from "./tab"; -import { BrowserWindow } from "@/controllers/windows-controller/types"; - -/** - * Pre-sleep state stored in memory so the serialization layer - * can persist the "real" URL/nav history even while the tab is asleep - * (webContents is destroyed during sleep). - */ -export interface PreSleepState { - url: string; - navHistory: Electron.NavigationEntry[]; - navHistoryIndex: number; -} - -/** - * Manages tab lifecycle transitions: sleep/wake, fullscreen, and picture-in-picture. - * - * Design notes: - * - Owns the pre-sleep state snapshot so serialization can access the "real" data - * - Reads tab state but mutates it only through tab.updateStateProperty() - * - Does NOT know about persistence or the controller - * - * Sleep/wake now destroys and recreates the WebContentsView entirely, - * saving ~20-50MB RAM per sleeping tab compared to the old approach - * of navigating to about:blank?sleep=true. - */ -export class TabLifecycleManager { - /** Snapshot of URL/nav state taken before the tab goes to sleep */ - public preSleepState: PreSleepState | null = null; - - /** Disconnect function for the window "leave-full-screen" listener */ - private disconnectLeaveFullScreen: (() => void) | null = null; - - constructor(private readonly tab: Tab) {} - - // --- Sleep / Wake --- - - /** - * Puts the tab to sleep to save resources. - * Captures a snapshot of the current URL and navigation history, - * then destroys the WebContentsView entirely. - * - * @param knownPreSleepState - If provided, use this as the pre-sleep state - * instead of reading from the tab. Used when constructing sleeping tabs - * from persisted data where webContents doesn't exist. - */ - putToSleep(knownPreSleepState?: PreSleepState): void { - if (this.tab.asleep) return; - - // Capture pre-sleep state before anything changes - if (knownPreSleepState) { - // Use the explicitly provided state (e.g. from restoration data) - this.preSleepState = knownPreSleepState; - } else { - this.tab.updateTabState(); // ensure state is fresh - - this.preSleepState = { - url: this.tab.url, - navHistory: [...this.tab.navHistory], - navHistoryIndex: this.tab.navHistoryIndex - }; - } - - this.tab.updateStateProperty("asleep", true); - - // Destroy the view and webContents to free resources - this.tab.teardownView(); - } - - /** - * Wakes a sleeping tab by recreating the WebContentsView and restoring - * navigation history from the pre-sleep state snapshot. - */ - wakeUp(): void { - if (!this.tab.asleep) return; - - const window = this.tab.getWindow(); - - // Recreate view, webContents, listeners, extensions - this.tab.initializeView(); - - // Add view to window's LayerManager - this.tab.setWindow(window); - - // Re-setup fullscreen listeners on the new webContents - this.setupFullScreenListeners(window); - - // Mark as awake - this.tab.updateStateProperty("asleep", false); - - // Restore navigation history from pre-sleep state - if (this.preSleepState) { - this.tab.restoreNavigationHistory(this.preSleepState.navHistory, this.preSleepState.navHistoryIndex); - this.preSleepState = null; - } - - // Apply background color for the restored URL (the "updated" listener - // won't fire because this.url was already set during sleep construction, - // so updateTabState() sees no URL change). - this.tab.applyUrlBackground(); - } - - // --- Fullscreen --- - - /** - * Enters or exits fullscreen for this tab. - * Coordinates with the Electron BrowserWindow fullscreen state. - */ - setFullScreen(isFullScreen: boolean): boolean { - const updated = this.tab.updateStateProperty("fullScreen", isFullScreen); - if (!updated) return false; - - const window = this.tab.getWindow(); - const electronWindow = window.browserWindow; - if (window.destroyed) return false; - - if (isFullScreen) { - if (!electronWindow.fullScreen) { - electronWindow.setFullScreen(true); - } - } else { - if (electronWindow.fullScreen) { - electronWindow.setFullScreen(false); - } - - const webContents = this.tab.webContents; - const view = this.tab.view; - if (webContents) { - // Slightly nudge the view bounds to force Chromium to recognize the - // viewport change, which is needed to properly exit HTML fullscreen. - if (view) { - setTimeout(() => { - const isViewValid = () => this.tab.view === view && this.tab.visible; - - if (!isViewValid()) return; - - const bounds = view.getBounds(); - const newBounds = { ...bounds, width: bounds.width - 1 }; - view.setBounds(newBounds); - - setTimeout(() => { - if (!isViewValid()) return; - - const currentBounds = view.getBounds(); - if (newBounds.width !== currentBounds.width) return; - if (newBounds.height !== currentBounds.height) return; - if (newBounds.x !== currentBounds.x) return; - if (newBounds.y !== currentBounds.y) return; - view.setBounds(bounds); - }, 50); - }, 800); - } - - webContents.executeJavaScript(`if (document.fullscreenElement) { document.exitFullscreen(); }`, true); - } - } - - // Notify the tab so layout can be updated - this.tab.emit("fullscreen-changed", isFullScreen); - - return true; - } - - /** - * Sets up fullscreen event listeners on the tab's webContents. - * Idempotent: disconnects previous listeners before registering new ones. - * Called during tab initialization and on wake from sleep. - */ - setupFullScreenListeners(window: BrowserWindow): void { - const webContents = this.tab.webContents; - if (!webContents) return; - - const electronWindow = window.browserWindow; - - webContents.on("enter-html-full-screen", () => { - this.setFullScreen(true); - }); - - webContents.on("leave-html-full-screen", () => { - // Always update tab fullscreen state directly. Don't rely solely on - // the indirect chain (electronWindow.setFullScreen(false) → window - // "leave-full-screen" event) because if the OS window is already not - // fullscreen, that event never fires and the tab stays stuck. - this.setFullScreen(false); - - // Also exit OS fullscreen if still active - if (electronWindow.fullScreen) { - electronWindow.setFullScreen(false); - } - }); - - // Disconnect previous leave-full-screen listener before registering a new one - if (this.disconnectLeaveFullScreen) { - this.disconnectLeaveFullScreen(); - this.disconnectLeaveFullScreen = null; - } - - const disconnectLeaveFullScreen = window.connect("leave-full-screen", () => { - this.setFullScreen(false); - }); - - this.disconnectLeaveFullScreen = disconnectLeaveFullScreen; - - this.tab.on("destroyed", () => { - if (window.isEmitterDestroyed()) return; - if (this.disconnectLeaveFullScreen) { - this.disconnectLeaveFullScreen(); - this.disconnectLeaveFullScreen = null; - } - }); - } - - // --- Picture-in-Picture --- - - /** - * Attempts to exit picture-in-picture mode for this tab. - * Used when a tab becomes visible again. - */ - async exitPictureInPicture(): Promise { - const webContents = this.tab.webContents; - if (!webContents) return false; - - // This function runs in the renderer context - const exitPiP = function () { - if (document.pictureInPictureElement) { - document.exitPictureInPicture(); - return true; - } - return false; - }; - - try { - const result = await webContents.executeJavaScript(`(${exitPiP})()`, true); - if (result === true) { - this.tab.updateStateProperty("isPictureInPicture", false); - return true; - } - } catch (err) { - console.error("PiP exit error:", err); - } - return false; - } - - /** - * Attempts to enter picture-in-picture mode for this tab. - * Used when a tab becomes hidden but has playing video. - */ - async enterPictureInPicture(): Promise { - const webContents = this.tab.webContents; - if (!webContents) return false; - - // This function runs in the renderer context - const enterPiP = async function () { - const videos = Array.from(document.querySelectorAll("video")).filter( - (video) => !video.paused && !video.ended && video.readyState > 2 - ); - - if (videos.length > 0 && document.pictureInPictureElement !== videos[0]) { - try { - const video = videos[0]; - await video.requestPictureInPicture(); - - const onLeavePiP = () => { - setTimeout(() => { - const goBackToTab = !video.paused && !video.ended; - flow.tabs.disablePictureInPicture(goBackToTab); - }, 50); - video.removeEventListener("leavepictureinpicture", onLeavePiP); - }; - - video.addEventListener("leavepictureinpicture", onLeavePiP); - return true; - } catch (e) { - console.error("Failed to enter Picture in Picture mode:", e); - return false; - } - } - return null; - }; - - try { - const result = await webContents.executeJavaScript(`(${enterPiP})()`, true); - if (result === true) { - this.tab.updateStateProperty("isPictureInPicture", true); - return true; - } - } catch (err) { - console.error("PiP enter error:", err); - } - return false; - } - - // --- Cleanup --- - - /** - * Called when the tab is being destroyed. - * Handles cleanup of fullscreen state if needed. - */ - onDestroy(): void { - if (this.tab.fullScreen) { - const window = this.tab.getWindow(); - if (!window.destroyed) { - window.browserWindow.setFullScreen(false); - } - } - } -} diff --git a/src/main/controllers/tabs-controller/tab-sync.ts b/src/main/controllers/tabs-controller/tab-sync.ts deleted file mode 100644 index eb8945fb9..000000000 --- a/src/main/controllers/tabs-controller/tab-sync.ts +++ /dev/null @@ -1,625 +0,0 @@ -/** - * Tab Sync — shared tab state across windows. - * - * When enabled, every window sees the same tabs. When a window gains focus, - * the active tab's WebContentsView is moved there. A screenshot placeholder - * is left in the old window. Disabled by default (each window has independent tabs). - */ - -import { getSettingValueById } from "@/saving/settings"; -import { windowsController } from "@/controllers/windows-controller"; -import { browserWindowsController } from "@/controllers/windows-controller/interfaces/browser"; -import type { BrowserWindow } from "@/controllers/windows-controller/types"; -import { spacesController } from "@/controllers/spaces-controller"; -import { pinnedTabsController } from "@/controllers/pinned-tabs-controller"; -import { - storeSnapshot, - removeSnapshot -} from "@/controllers/sessions-controller/protocols/_protocols/flow-internal/tab-snapshot"; -import type { TabPlaceholderUpdate } from "~/types/tabs"; -import { Tab } from "./tab"; -import { BaseTabGroup } from "./tab-groups"; -import { type TabsController } from "./index"; - -// TabsController registration (avoids circular dependency) - -let _tabsController: TabsController | null = null; - -/** Called from TabsController constructor to avoid circular imports. */ -export function registerTabsController(tc: TabsController): void { - _tabsController = tc; -} - -function getTabsController(): TabsController { - if (!_tabsController) { - throw new Error("[tab-sync] TabsController not registered yet. Call registerTabsController() first."); - } - return _tabsController; -} - -// Screenshot placeholders (served via flow-internal://tab-snapshot) -const PLACEHOLDER_RELEASE_DELAY_MS = 180; - -type WindowPlaceholderState = { - snapshotId: string; - tabId: number; - generation: number; - spaceId: string; -}; - -/** Current placeholder state per window, for cleanup. */ -const windowPlaceholderState: Map = new Map(); -const windowPlaceholderGeneration: Map = new Map(); - -function nextPlaceholderGeneration(windowId: number): number { - const generation = (windowPlaceholderGeneration.get(windowId) ?? 0) + 1; - windowPlaceholderGeneration.set(windowId, generation); - return generation; -} - -function sendPlaceholderUpdate(targetWindow: BrowserWindow, update: TabPlaceholderUpdate): void { - if (targetWindow.destroyed) return; - targetWindow.sendMessageToCoreWebContents("tabs:on-placeholder-changed", update); -} - -/** - * Keeps the underlying Electron view hidden while a tab is transferred - * between windows. The sync/close flows intentionally bypass the normal - * layout manager when marking the tab dormant, so we must mirror the - * model-level `visible = false` flag onto the actual WebContentsView. - */ -function prepareTabForWindowTransfer(tab: Tab): void { - tab.visible = false; - if (tab.layer) { - tab.layer.setVisible(false); - } -} - -/** - * Captures a screenshot of the tab. Must be called while the view is still - * attached — capturePage returns empty once the view is detached. - */ -async function captureTabScreenshot(tab: Tab): Promise { - const wc = tab.webContents; - if (!wc || wc.isDestroyed()) return null; - - const view = tab.view; - if (!view) return null; - - const bounds = view.getBounds(); - if (bounds.width <= 0 || bounds.height <= 0) return null; - - try { - const image = await wc.capturePage({ x: 0, y: 0, width: bounds.width, height: bounds.height }); - return image.isEmpty() ? null : image; - } catch { - return null; - } -} - -/** Stores a snapshot and sends its ID to the target window's renderer. */ -function sendPlaceholderToRenderer( - targetWindow: BrowserWindow, - spaceId: string, - tabId: number, - image: Electron.NativeImage -): void { - if (targetWindow.destroyed) return; - - const previousPlaceholder = windowPlaceholderState.get(targetWindow.id); - if (previousPlaceholder) { - removeSnapshot(previousPlaceholder.snapshotId); - } - - const generation = nextPlaceholderGeneration(targetWindow.id); - const snapshotId = storeSnapshot(image); - windowPlaceholderState.set(targetWindow.id, { snapshotId, tabId, generation, spaceId }); - sendPlaceholderUpdate(targetWindow, { snapshotId, generation, spaceId }); -} - -/** Clears the placeholder in a window and frees the stored snapshot. */ -function clearPlaceholderInRenderer(windowId: number): void { - const generation = nextPlaceholderGeneration(windowId); - const placeholderState = windowPlaceholderState.get(windowId); - if (placeholderState) { - windowPlaceholderState.delete(windowId); - setTimeout(() => { - removeSnapshot(placeholderState.snapshotId); - }, PLACEHOLDER_RELEASE_DELAY_MS); - } - - const win = browserWindowsController.getWindowById(windowId); - if (!win) return; - - sendPlaceholderUpdate(win, { snapshotId: null, generation, spaceId: win.currentSpaceId }); -} - -/** Clears any placeholders currently showing a screenshot for the destroyed tab. */ -export function clearPlaceholdersForTab(tabId: number): void { - for (const [windowId, placeholderState] of windowPlaceholderState.entries()) { - if (placeholderState.tabId !== tabId) continue; - clearPlaceholderInRenderer(windowId); - } -} - -/** - * Clears a window's placeholder when its currently visible space no longer - * points at any remote syncable tab. Placeholders are window-wide in the - * renderer, so without this reconciliation a screenshot from Space A can - * linger after switching the window to Space B. - */ -function reconcilePlaceholderForWindow(windowId: number): void { - const tabsController = getTabsController(); - const window = browserWindowsController.getWindowById(windowId); - if (!window || window.destroyed || window.browserWindowType !== "normal") return; - - const spaceId = window.currentSpaceId; - if (!spaceId) { - clearPlaceholderInRenderer(windowId); - return; - } - - const activeTabOrGroup = tabsController.getActiveTab(windowId, spaceId); - if (!activeTabOrGroup) { - clearPlaceholderInRenderer(windowId); - return; - } - - const syncableTabs = - activeTabOrGroup instanceof Tab - ? isSyncExcludedTab(activeTabOrGroup) - ? [] - : [activeTabOrGroup] - : activeTabOrGroup.tabs.filter((tab) => !isSyncExcludedTab(tab)); - - if (syncableTabs.length === 0) { - clearPlaceholderInRenderer(windowId); - return; - } - - const hasRemoteActiveTab = syncableTabs.some((tab) => tab.getWindow().id !== windowId); - if (!hasRemoteActiveTab) { - clearPlaceholderInRenderer(windowId); - } -} - -// Core helpers - -export function isTabSyncEnabled(): boolean { - return getSettingValueById("syncTabsAcrossWindows") === true; -} - -/** Returns true if the tab belongs to an internal profile (e.g. incognito). */ -export function isInternalProfileTab(tab: Tab): boolean { - return tab.loadedProfile.profileData.internal === true; -} - -/** Returns true if the tab currently belongs to a popup window. */ -export function isPopupWindowTab(tab: Tab): boolean { - return tab.getWindow().browserWindowType === "popup"; -} - -/** Returns true if the tab should be excluded from tab sync (internal or popup). */ -export function isSyncExcludedTab(tab: Tab): boolean { - return isInternalProfileTab(tab) || isPopupWindowTab(tab); -} - -function shouldSyncSharedActiveTab(window: BrowserWindow, spaceId: string): boolean { - if (isTabSyncEnabled()) return true; - - const tabsController = getTabsController(); - const activeTabOrGroup = tabsController.getActiveTab(window.id, spaceId); - return activeTabOrGroup instanceof Tab && pinnedTabsController.getPinnedIdByTabId(activeTabOrGroup.id) !== null; -} - -/** - * Moves the active tab/group for a window-space into the given window. - * Captures a screenshot before moving so the old window gets a placeholder. - * - * @param isStale — optional callback that returns true when a newer focus - * event has fired, so this (now-outdated) move should be abandoned. - */ -async function moveActiveTabToWindow(window: BrowserWindow, isStale?: () => boolean): Promise { - const tabsController = getTabsController(); - const spaceId = window.currentSpaceId; - if (!spaceId) return; - - const activeTabOrGroup = tabsController.getActiveTab(window.id, spaceId); - if (!activeTabOrGroup) return; - - clearPlaceholderInRenderer(window.id); - - if (activeTabOrGroup instanceof Tab) { - // Internal-profile and popup-window tabs must not be synced across windows - if (isSyncExcludedTab(activeTabOrGroup)) return; - await moveTabToWindowIfNeeded(activeTabOrGroup, window, isStale); - } else if (activeTabOrGroup instanceof BaseTabGroup) { - // If any tab in the group is excluded from sync, skip the entire group move - if (activeTabOrGroup.tabs.some(isSyncExcludedTab)) return; - // Check staleness before starting the group move. Once begun, complete - // the full group to avoid leaving it split across windows. - if (isStale?.()) return; - for (const tab of activeTabOrGroup.tabs) { - await moveTabToWindowIfNeeded(tab, window); - } - } -} - -/** - * Moves a single tab's view to a window if it isn't already there. - * The placeholder is sent BEFORE moving so it loads behind the native view, - * eliminating flicker. Resets `tab.visible` so the new window re-shows it. - * - * @param isStale — optional callback checked after the async screenshot - * capture. If it returns true the move is abandoned (a newer focus event - * superseded this one). - */ -async function moveTabToWindowIfNeeded(tab: Tab, window: BrowserWindow, isStale?: () => boolean): Promise { - if (tab.isDestroyed || window.destroyed) return; - if (tab.getWindow().id !== window.id) { - const oldWindow = tab.getWindow(); - if (oldWindow.destroyed) return; - - // Capture before the move — view must be attached for a valid surface - const screenshot = await captureTabScreenshot(tab); - - // A newer focus event arrived while we were capturing — abort - if (isStale?.()) return; - if (tab.isDestroyed || window.destroyed || oldWindow.destroyed) return; - - // Send placeholder to old window before moving (loads behind the native view) - if (screenshot) { - sendPlaceholderToRenderer(oldWindow, tab.spaceId, tab.id, screenshot); - } - - // Move the tab to the new window - prepareTabForWindowTransfer(tab); - tab.setWindow(window); - - // Reset cached bounds so the layout manager re-applies for the new window - const tabsController = getTabsController(); - const layoutManager = tabsController.getLayoutManager(tab.id); - layoutManager?.onWindowChanged(); - } -} - -/** - * Moves a tab (and its group members) to a window with placeholder handling. - * Used by IPC handlers (e.g. `tabs:switch-to-tab`). - */ -export async function moveTabOrGroupToWindow(tab: Tab, window: BrowserWindow): Promise { - const tabsController = getTabsController(); - - clearPlaceholderInRenderer(window.id); - - const tabGroup = tabsController.getTabGroupByTabId(tab.id); - if (tabGroup) { - for (const groupTab of tabGroup.tabs) { - await moveTabToWindowIfNeeded(groupTab, window); - } - } else { - await moveTabToWindowIfNeeded(tab, window); - } -} - -// Helper to find a window with a specific profile active in its current space -function findWindowWithProfile(windows: BrowserWindow[], profileId: string): BrowserWindow | null { - for (const win of windows) { - const spaceId = win.currentSpaceId; - if (!spaceId) continue; - const space = spacesController.getFromCache(spaceId); - if (space?.profileId === profileId) { - return win; - } - } - return null; -} - -/** - * Relocates tabs from a closing window to a surviving window. - * - * Called from BrowserWindow.destroy(). When sync is enabled and other browser - * windows exist, tabs are moved instead of destroyed so the shared tab set - * survives the window close. - * - * Internal-profile (e.g. incognito) tabs can only relocate to a surviving - * window that has the same profile active in its current space. If no such - * window exists, they are returned as unrelocatable for destruction. - * - * @param tabs Tabs that belonged to the closing window (captured before the - * window was removed from the controller). - * @returns The list of tabs that were **not** relocated and still need - * destruction, or `null` when sync is disabled / no surviving - * windows exist (meaning the caller should destroy all tabs). - */ -export function relocateTabsFromClosingWindow(closingWindow: BrowserWindow, tabs: Tab[]): Tab[] | null { - if (!isTabSyncEnabled()) return null; - - const closingWindowId = closingWindow.id; - // Popup-window tabs should never be relocated to normal windows - if (closingWindow.browserWindowType === "popup") return null; - - const survivingWindows = browserWindowsController - .getWindows() - .filter((w) => w.id !== closingWindowId && w.browserWindowType === "normal"); - if (survivingWindows.length === 0) return null; - - const tabsController = getTabsController(); - const defaultTargetWindow = survivingWindows[0]; - - // Tabs from internal profiles (e.g. incognito) can only relocate to windows - // with the same profile active. Regular tabs can relocate to any window. - const relocatable = new Map(); - const unrelocatable: Tab[] = []; - - for (const tab of tabs) { - const isInternal = tab.loadedProfile.profileData.internal; - if (isInternal) { - // Try to find a window with the same profile - const targetWindow = findWindowWithProfile(survivingWindows, tab.profileId); - if (targetWindow) { - const list = relocatable.get(targetWindow) ?? []; - list.push(tab); - relocatable.set(targetWindow, list); - } else { - unrelocatable.push(tab); - } - } else { - // Regular tabs go to the default target - const list = relocatable.get(defaultTargetWindow) ?? []; - list.push(tab); - relocatable.set(defaultTargetWindow, list); - } - } - - // Relocate tabs to their respective target windows - for (const [targetWindow, windowTabs] of relocatable) { - for (const tab of windowTabs) { - prepareTabForWindowTransfer(tab); - tab.setWindow(targetWindow); - - const layoutManager = tabsController.getLayoutManager(tab.id); - layoutManager?.onWindowChanged(); - } - } - - // Unrelocatable tabs are about to be destroyed. Clear any active/focused - // references that surviving windows hold to these tabs so that - // relocateDisplacedTabs doesn't try (and fail) to move them. - if (unrelocatable.length > 0) { - const unrelocatableIds = new Set(unrelocatable.map((t) => t.id)); - for (const win of survivingWindows) { - const spaceId = win.currentSpaceId; - if (!spaceId) continue; - - const active = tabsController.getActiveTab(win.id, spaceId); - if (!active) continue; - - // Check if the active element is (or contains) an unrelocatable tab - const isStale = - active instanceof Tab - ? unrelocatableIds.has(active.id) - : active.tabs.some((t: Tab) => unrelocatableIds.has(t.id)); - - if (isStale) { - tabsController.removeActiveTab(win.id, spaceId); - } - } - } - - // Purge stale map entries for the closing window - tabsController.cleanupWindowEntries(closingWindowId); - - // Re-run layout so each target window shows the correct active tab - for (const targetWindow of relocatable.keys()) { - const targetSpaceId = targetWindow.currentSpaceId; - if (targetSpaceId) { - tabsController.emit("active-tab-changed", targetWindow.id, targetSpaceId); - } - } - - return unrelocatable; -} - -// Automatic tab relocation - -let _syncMoveQueue: Promise = Promise.resolve(); - -async function runTabSyncMutation(work: () => Promise): Promise { - const run = _syncMoveQueue.then(work, work); - _syncMoveQueue = run.then( - () => undefined, - () => undefined - ); - return run; -} - -let _relocating = false; -let _relocateRequested = false; - -/** - * Finds tabs whose views are in the wrong window and moves them back. - * - * After a tab switch in Window A, the previously-active tab may still have - * its WebContentsView attached to A even though Window B has it as active. - * This function detects that situation and moves the view to B, clearing - * the placeholder there. - * - * Guard: if the tab is active in BOTH the current owner window and the - * target window (e.g. right after a focus-move), the tab usually stays put. - * The exception is when the target window is currently focused: a space switch - * inside that focused window does not emit a new focus event, so the tab must - * still be reclaimed there. - */ -async function relocateDisplacedTabs(): Promise { - _relocateRequested = true; - if (_relocating) return; - _relocating = true; - - try { - while (_relocateRequested) { - _relocateRequested = false; - - await runTabSyncMutation(async () => { - const tabsController = getTabsController(); - const allWindows = browserWindowsController.getWindows().filter((w) => w.browserWindowType === "normal"); - - // Build a map: windowId -> all active tabs for its current space. - // For tab groups, every member tab is included so that the full group - // is relocated together (not just the first/representative tab). - const windowActiveTabs = new Map(); - const windowWantedTabIds = new Map>(); - - for (const win of allWindows) { - const spaceId = win.currentSpaceId; - if (!spaceId) continue; - - const active = tabsController.getActiveTab(win.id, spaceId); - if (!active) continue; - - const tabs: Tab[] = active instanceof Tab ? [active] : [...active.tabs]; - - // Internal-profile and popup-window tabs are not synced — skip them - const syncableTabs = tabs.filter((t) => !isSyncExcludedTab(t)); - if (syncableTabs.length === 0) continue; - - windowActiveTabs.set(win.id, syncableTabs); - windowWantedTabIds.set(win.id, new Set(syncableTabs.map((t) => t.id))); - } - - // For each window, check if any of its wanted tabs are in the wrong window - for (const [targetWindowId, tabs] of windowActiveTabs) { - for (const tab of tabs) { - if (tab.isDestroyed) { - continue; - } - const viewOwnerWindowId = tab.getWindow().id; - if (viewOwnerWindowId === targetWindowId) continue; // already here - - // If the owner window no longer exists (destroyed), the tab is - // orphaned and will be cleaned up by its scheduled destruction. - // Attempting to relocate it would fail and re-trigger this - // function in an infinite loop. - if (!browserWindowsController.getWindowById(viewOwnerWindowId)) continue; - - const targetWindow = browserWindowsController.getWindowById(targetWindowId); - if (!targetWindow) continue; - - // Is the tab also wanted by the window that currently owns the view? - const ownerWanted = windowWantedTabIds.get(viewOwnerWindowId); - if (ownerWanted?.has(tab.id) && !targetWindow.browserWindow.isFocused()) { - // Both windows want this tab and the target window is not - // focused — don't steal the view from the current owner. - continue; - } - - clearPlaceholderInRenderer(targetWindowId); - - await moveTabToWindowIfNeeded(tab, targetWindow); - - // Let processActiveTabChange re-show the tab in the target window - const spaceId = targetWindow.currentSpaceId; - if (spaceId) { - tabsController.emit("active-tab-changed", targetWindowId, spaceId); - } - } - } - }); - } - } finally { - _relocating = false; - } -} - -// Focus-move staleness detection -// -// When the app regains focus, the OS/Electron can fire a transient `focus` -// event on the wrong window before the real target receives focus. Both -// events trigger async tab moves that race. The generation counter lets -// the stale move bail out after its async screenshot capture completes. - -let _focusMoveGeneration = 0; - -/** Initializes tab sync listeners. Call once at app startup. */ -export function initTabSync(): void { - // Move the active tab's view to the focused window - windowsController.on("window-focused", (id) => { - const window = browserWindowsController.getWindowById(id); - if (!window || window.browserWindowType !== "normal") return; - - const generation = ++_focusMoveGeneration; - const isStale = () => generation !== _focusMoveGeneration; - - // Async: capture screenshot, move tab, then emit active-tab-changed - runTabSyncMutation(async () => { - if (window.destroyed || isStale()) return; - const spaceId = window.currentSpaceId; - if (!spaceId) return; - if (isStale()) return; - - // Pinned-tab associations always sync across windows regardless of the - // syncTabsAcrossWindows setting. For regular tabs, only proceed when - // tab sync is enabled. - if (!shouldSyncSharedActiveTab(window, spaceId)) return; - - await moveActiveTabToWindow(window, isStale); - if (isStale()) return; - const currentSpaceId = window.currentSpaceId; - if (!currentSpaceId) return; - const tabsController = getTabsController(); - tabsController.focusActiveTab(window.id, currentSpaceId); - tabsController.emit("active-tab-changed", window.id, currentSpaceId); - }).catch((err) => { - console.error("[tab-sync] Failed to move active tab on focus:", err); - }); - }); - - // Relocate displaced tabs when the active tab or space changes - const tabsController = getTabsController(); - - tabsController.on("active-tab-changed", (windowId) => { - reconcilePlaceholderForWindow(windowId); - if (!isTabSyncEnabled()) return; - relocateDisplacedTabs().catch((err) => { - console.error("[tab-sync] Failed to relocate displaced tabs:", err); - }); - }); - - tabsController.on("current-space-changed", (windowId) => { - reconcilePlaceholderForWindow(windowId); - - const window = browserWindowsController.getWindowById(windowId); - if (window && window.browserWindowType === "normal") { - const expectedSpaceId = window.currentSpaceId; - if (expectedSpaceId && shouldSyncSharedActiveTab(window, expectedSpaceId)) { - const isStale = () => window.currentSpaceId !== expectedSpaceId; - - runTabSyncMutation(async () => { - if (window.destroyed || isStale()) return; - await moveActiveTabToWindow(window, isStale); - if (isStale()) return; - - const tabsController = getTabsController(); - tabsController.focusActiveTab(window.id, expectedSpaceId); - tabsController.emit("active-tab-changed", window.id, expectedSpaceId); - }).catch((err) => { - console.error("[tab-sync] Failed to move active tab on space change:", err); - }); - } - } - - if (!isTabSyncEnabled()) return; - relocateDisplacedTabs().catch((err) => { - console.error("[tab-sync] Failed to relocate displaced tabs on space change:", err); - }); - }); - - // Clean up placeholders and stale map entries when windows are destroyed - windowsController.on("window-removed", (id) => { - clearPlaceholderInRenderer(id); - windowPlaceholderGeneration.delete(id); - tabsController.cleanupWindowEntries(id); - }); -} - -export { runTabSyncMutation }; diff --git a/src/main/controllers/tabs-controller/tab.ts b/src/main/controllers/tabs-controller/tab.ts deleted file mode 100644 index 91f4bc91a..000000000 --- a/src/main/controllers/tabs-controller/tab.ts +++ /dev/null @@ -1,977 +0,0 @@ -import { - isHistoryRecordableUrl, - recordBrowsingHistoryVisit, - updateBrowsingHistoryTitleForOpenPage -} from "@/saving/history/browsing-history"; -import { cacheFavicon } from "@/modules/favicons"; -import { FLAGS } from "@/modules/flags"; -import { TypedEventEmitter } from "@/modules/typed-event-emitter"; -import { NavigationEntry, Session, WebContents, WebContentsView, WebPreferences } from "electron"; -import { Layer } from "@/controllers/windows-controller/layer-manager"; -import { createTabContextMenu } from "./context-menu"; -import { generateID, getCurrentTimestamp } from "@/modules/utils"; -import { BrowserWindow } from "@/controllers/windows-controller/types"; -import { LoadedProfile } from "@/controllers/loaded-profiles-controller"; -import { createModalTo, focusPriorities, type LayerType, zIndexes } from "~/layers"; -import { type TabsController } from "./index"; - -export const SLEEP_MODE_URL = "about:blank?sleep=true"; - -// Stable counter-based tab IDs (independent of webContents.id). -// This allows tab.id to remain constant across sleep/wake cycles -// where the webContents is destroyed and recreated. -let nextTabId = 1; - -// Interfaces and Types -interface PatchedWebContentsView extends WebContentsView { - destroy: () => void; -} - -type TabStateProperty = - | "visible" - | "isDestroyed" - | "faviconURL" - | "fullScreen" - | "isPictureInPicture" - | "asleep" - | "lastActiveAt" - | "position"; -type TabContentProperty = "title" | "url" | "isLoading" | "audible" | "muted" | "navHistory" | "navHistoryIndex"; - -export type TabPublicProperty = TabStateProperty | TabContentProperty; - -export type TabEvents = { - "space-changed": []; - "window-changed": [oldWindowId: number]; - "fullscreen-changed": [boolean]; - "new-tab-requested": [ - string, - "new-window" | "foreground-tab" | "background-tab" | "default" | "other", - Electron.WebContentsViewConstructorOptions | undefined, - Electron.HandlerDetails | undefined, - { noLoadURL?: boolean } - ]; - focused: []; - // Updated property keys - updated: [TabPublicProperty[]]; - destroyed: []; -}; - -export interface TabCreationDetails { - // Controllers - tabsController: TabsController; - - // Properties - profileId: string; - spaceId: string; - - // Session - session: Session; - - // Loaded Profile - loadedProfile: LoadedProfile; -} - -export interface TabCreationOptions { - uniqueId?: string; - window: BrowserWindow; - webContentsViewOptions?: Electron.WebContentsViewConstructorOptions; - - /** - * Persisted timestamps for restored tabs. - * Omit for fresh tabs so constructor uses current time. - */ - createdAt?: number; - lastActiveAt?: number; - - // Options - url?: string; - asleep?: boolean; - position?: number; - - // When true, the tab will not be persisted to the database. - // Used for pinned-tab-associated tabs that should not survive across sessions. - ephemeral?: boolean; - - // Old States to be restored - title?: string; - faviconURL?: string; - navHistory?: NavigationEntry[]; - navHistoryIndex?: number; - - // Others - noLoadURL?: boolean; - /** - * When true, `TabsController` applies typed intent for the initial `loadURL(initialURL)` using that - * same URL string (see `markTypedNavigationForNextHistoryVisit`). - */ - typedNavigation?: boolean; -} - -function createWebContentsView( - session: Session, - options: Electron.WebContentsViewConstructorOptions -): PatchedWebContentsView { - const webContents = options.webContents; - const webPreferences: WebPreferences = { - // Merge with any additional preferences - ...(options.webPreferences || {}), - - // Basic preferences - sandbox: true, - webSecurity: true, - session: session, - scrollBounce: true, - safeDialogs: true, - navigateOnDragDrop: true, - transparent: true, - - // nodeIntegration = false and nodeIntegrationInSubFrames = true disables node in renderer + enable preload scripts in iframes - // https://github.com/electron/electron/issues/22582#issuecomment-704247482 - nodeIntegration: false, - nodeIntegrationInSubFrames: true, - contextIsolation: true - - // Provide access to 'flow' globals (replaced by implementation in protocols.ts) - // preload: PATHS.PRELOAD - }; - - const webContentsView = new WebContentsView({ - webPreferences, - // Only add webContents if it is provided - ...(webContents ? { webContents } : {}) - }); - - webContentsView.setVisible(false); - return webContentsView as PatchedWebContentsView; -} - -/** - * Tab class — owns identity, state, WebContentsView, and event emission. - * - * The view and webContents are nullable: sleeping tabs have no view or - * webContents to save resources (~20-50MB RAM per sleeping tab). - * On wake, initializeView() recreates them from scratch with navigation - * history restored. - * - * Does NOT own: - * - Layout/bounds (TabLayoutManager) - * - Sleep/wake/fullscreen/PiP lifecycle (TabLifecycleManager) - * - Persistence (TabPersistenceManager listens to events) - * - New tab creation (emits "new-tab-requested", TabsController handles it) - */ -export class Tab extends TypedEventEmitter { - // Identity (stable across sleep/wake cycles) - public readonly id: number; - public groupId: string | null = null; - public readonly profileId: string; - public spaceId: string; - public readonly uniqueId: string; - - // State properties - public visible: boolean = false; - public isDestroyed: boolean = false; - public faviconURL: string | null = null; - public fullScreen: boolean = false; - public isPictureInPicture: boolean = false; - public asleep: boolean = false; - public createdAt: number; - public lastActiveAt: number; - public position: number; - /** When true, this tab is not saved to the database and will not survive app restart. */ - public ephemeral: boolean; - - /** - * When set, the next recorded http(s) visit counts as typed only if it commits to this exact URL - * (avoids crediting a later navigation after a cancelled load). - */ - private pendingHistoryTypedUrl: string | null = null; - /** - * Canonical key of the last http(s) visit we stored for this tab in this WebContents - * lifetime. If a new visit matches this key, it is skipped (refresh, SPA re-fires, etc.). - */ - private lastRecordedHistoryKey: string = ""; - - // Content properties (from WebContents) - public title: string = "New Tab"; - public url: string = ""; - public isLoading: boolean = false; - public audible: boolean = false; - public muted: boolean = false; - public navHistory: NavigationEntry[] = []; - public navHistoryIndex: number = 0; - - // Cached for nav history diff (avoids JSON.stringify every time) - private lastNavHistoryLength: number = 0; - private lastNavHistoryIndex: number = 0; - - // Coalescing flag for updateTabState — defers to microtask so rapid - // webContents events (did-start-loading, did-navigate, title-updated, …) - // are batched into a single state read + emit per event-loop tick. - private _updatePending: boolean = false; - - // View & content objects — nullable (null when tab is asleep) - public view: PatchedWebContentsView | null = null; - public webContents: WebContents | null = null; - public layer: Layer | null = null; - - // Private properties - private readonly session: Session; - public readonly loadedProfile: LoadedProfile; - private window!: BrowserWindow; - // Kept for context menu setup; will be removed when context menu is refactored - private readonly tabsController: TabsController; - // Stored for recreating the view on wake - private readonly _webContentsViewOptions: Electron.WebContentsViewConstructorOptions; - - /** - * Creates a new tab instance. - * - * Two construction paths: - * - Awake: creates WebContentsView, wires up listeners, registers with extensions - * - Sleeping: stores state only, no view/webContents created (saves resources) - * - * Navigation history restoration and initial URL loading are deferred to - * setImmediate so the TabsController can finish wiring up the lifecycle/layout - * managers first. - */ - constructor(details: TabCreationDetails, options: TabCreationOptions) { - super(); - - const { tabsController, profileId, spaceId, session } = details; - - this.tabsController = tabsController; - this.profileId = profileId; - this.spaceId = spaceId; - this.session = session; - this.loadedProfile = details.loadedProfile; - - // Options - const { - window, - webContentsViewOptions = {}, - createdAt, - lastActiveAt, - asleep = false, - position, - title, - faviconURL, - navHistory = [], - navHistoryIndex, - uniqueId, - ephemeral = false - } = options; - - this._webContentsViewOptions = webContentsViewOptions; - this.uniqueId = uniqueId || generateID(); - this.ephemeral = ephemeral; - - // Stable counter-based ID (independent of webContents.id) - this.id = nextTabId++; - - // Position: if not provided, the caller (TabsController) should have computed it - if (position !== undefined) { - this.position = position; - } else { - const smallestPosition = tabsController.getSmallestPosition(); - this.position = smallestPosition - 1; - } - - // Set creation time - const now = getCurrentTimestamp(); - this.createdAt = createdAt ?? now; - this.lastActiveAt = lastActiveAt ?? this.createdAt; - - // Restore visual states - if (title) this.title = title; - if (faviconURL) this.faviconURL = faviconURL; - - // Tab-level listeners (registered once, survive sleep/wake cycles, with null guards) - this.setupTabLevelListeners(); - - if (asleep) { - // --- SLEEPING PATH --- - // No view or webContents created. The tab stores only state. - // The TabsController will set lifecycleManager.preSleepState after construction. - this.asleep = true; - this.window = window; - - // Store URL and nav history from creation options for renderer display - if (navHistory.length > 0) { - const idx = navHistoryIndex ?? navHistory.length - 1; - this.url = navHistory[idx]?.url ?? ""; - this.navHistory = [...navHistory]; - this.navHistoryIndex = idx; - this.lastNavHistoryLength = navHistory.length; - this.lastNavHistoryIndex = idx; - } - - this._needsInitialLoad = false; - } else { - // --- AWAKE PATH --- - // Set window reference first (needed by initializeView for extensions registration) - this.window = window; - - // Create view, webContents, listeners, context menu, extensions - this.initializeView(); - - // Add view to window's LayerManager - this.setWindow(window); - - // Restore navigation history (deferred to let managers wire up) - const restoreNavHistory = navHistory.length > 0; - if (restoreNavHistory) { - setImmediate(() => { - this.restoreNavigationHistory(navHistory, navHistoryIndex ?? navHistory.length - 1); - }); - } - - this._needsInitialLoad = !restoreNavHistory; - } - } - - // --- Internal state for deferred initialization --- - - /** Whether the tab needs its initial URL loaded */ - public _needsInitialLoad: boolean = false; - /** - * Set by the controller when handling "new-tab-requested". - * The setWindowOpenHandler's createWindow callback reads this synchronously. - */ - public _lastCreatedWebContents: WebContents | null = null; - - // --- View Lifecycle --- - - /** - * Creates the WebContentsView, sets up event listeners, context menu, - * window open handler, and registers with the extensions system. - * - * Precondition: this.window must be set before calling. - * Called on construction (awake path) and on wake from sleep. - */ - public initializeView(): void { - if (this.view) return; // Already initialized - - this.lastRecordedHistoryKey = ""; - this.pendingHistoryTypedUrl = null; - - const webContentsView = createWebContentsView(this.session, this._webContentsViewOptions); - const webContents = webContentsView.webContents; - - this.view = webContentsView; - this.webContents = webContents; - this.layer = new Layer( - this.window.layerManager, - webContentsView, - zIndexes.tab, - focusPriorities.tab, - createModalTo("tab") - ); - - // Apply muted state if tab was muted before sleeping - if (this.muted) { - webContents.setAudioMuted(true); - } - - // Setup event listeners on webContents (auto-cleanup on destroy) - this.setupWebContentsListeners(); - - // Setup window open handler (auto-cleanup on destroy) - this.setupWindowOpenHandler(); - - // Setup context menu (binds to webContents, auto-cleans on destroy) - createTabContextMenu(this.tabsController, this, this.profileId, this.window, this.spaceId); - - // Register with extensions - const extensions = this.loadedProfile.extensions; - extensions.addTab(webContents, this.window.browserWindow); - - // Target URL (hover link preview — sent to shell UI, not TabData) - this.webContents.on("update-target-url", (_event, url) => { - this.sendTargetUrlToRenderer(url); - }); - } - - private sendTargetUrlToRenderer(url: string) { - const window = this.getWindow(); - if (window.destroyed) return; - window.sendMessageToCoreWebContents("tabs:on-target-url", { - tabId: this.id, - windowId: window.id, - url - }); - } - - /** - * Destroys the WebContentsView and webContents, freeing resources. - * Called when the tab is put to sleep. - */ - public teardownView(): void { - if (!this.view || !this.webContents) return; - - this.sendTargetUrlToRenderer(""); - - this.removeViewFromWindow(); - - if (!this.webContents.isDestroyed()) { - this.webContents.close(); - } - - this.view = null; - this.webContents = null; - this.layer = null; - } - - /** - * Restores navigation history on the current webContents. - * Used when waking a sleeping tab. - */ - public restoreNavigationHistory(navHistory: NavigationEntry[], navHistoryIndex: number): void { - if (!this.webContents) return; - this.webContents.navigationHistory.restore({ - entries: navHistory, - index: navHistoryIndex - }); - } - - // --- Background Color --- - - private static readonly WHITELISTED_PROTOCOLS = ["flow-internal:", "flow:"]; - private static readonly COLOR_TRANSPARENT = "#00000000"; - private static readonly COLOR_BACKGROUND = "#ffffffff"; - - /** - * Applies the correct background color based on the current URL. - * Internal protocols (flow:, flow-internal:) get a transparent background; - * everything else gets an opaque white background. - */ - public applyUrlBackground(): void { - if (!this.url || !this.view) return; - const url = URL.parse(this.url); - if (url && Tab.WHITELISTED_PROTOCOLS.includes(url.protocol)) { - this.view.setBackgroundColor(Tab.COLOR_TRANSPARENT); - } else { - this.view.setBackgroundColor(Tab.COLOR_BACKGROUND); - } - } - - // --- Tab-Level Listeners (registered once, survive sleep/wake cycles) --- - - /** - * Sets up listeners on the Tab event emitter (not on webContents). - * These persist across sleep/wake cycles and use null guards for - * view/webContents access. - */ - private setupTabLevelListeners() { - this.on("updated", () => { - if (!this.webContents) return; - this.webContents.emit("tab-updated"); - }); - - // Transparent background for internal protocols - this.on("updated", (properties) => { - if (properties.includes("url")) { - this.applyUrlBackground(); - } - }); - } - - // --- WebContents Listeners (re-created on each wake) --- - - /** - * Sets up event listeners on the webContents. - * These auto-cleanup when the webContents is destroyed (on sleep). - * Called from initializeView(). - */ - private setupWebContentsListeners() { - const webContents = this.webContents!; - - webContents.on("page-title-updated", (_event, title) => { - if (this.loadedProfile.profileData.ephemeral) return; - if (!this.tabsController.isTabActive(this)) return; - const url = webContents.getURL(); - if (!isHistoryRecordableUrl(url)) return; - updateBrowsingHistoryTitleForOpenPage({ - profileId: this.profileId, - url, - title - }); - }); - - // Set zoom level limits when webContents is ready - webContents.on("did-finish-load", () => { - webContents.setVisualZoomLevelLimits(1, 5); - this.recordBrowsingHistoryFromWebContents(webContents); - }); - - webContents.on("did-navigate-in-page", (_event, url, isMainFrame) => { - if (!isMainFrame) return; - this.recordBrowsingHistoryFromWebContents(webContents, url); - }); - - // Note: Fullscreen listeners are set up by TabLifecycleManager - - // Focus tracking (used by TabsController to determine focused tab) - webContents.on("focus", () => { - this.emit("focused"); - }); - - // Handle favicon updates - webContents.on("page-favicon-updated", (_event, favicons) => { - const faviconURL = favicons[0]; - const url = webContents.getURL(); - if (faviconURL && url) { - cacheFavicon(url, faviconURL, this.session); - } - if (faviconURL && faviconURL !== this.faviconURL) { - this.updateStateProperty("faviconURL", faviconURL); - } - }); - - // Handle page load errors - webContents.on("did-fail-load", (event, errorCode, _errorDescription, validatedURL, isMainFrame) => { - event.preventDefault(); - // Skip aborted operations (user navigation cancellations) - if (isMainFrame && errorCode !== -3) { - this.loadErrorPage(errorCode, validatedURL); - } - }); - - // Handle devtools open url — emit event instead of calling controller - webContents.on("devtools-open-url", (_event, url) => { - this.emit("new-tab-requested", url, "foreground-tab", undefined, undefined, { noLoadURL: false }); - }); - - // Handle content state changes. - // Events are coalesced via scheduleUpdateTabState() so that a burst of - // events during page load (did-start-loading + did-start-navigation + - // page-title-updated + …) results in a single updateTabState() call - // per microtask tick instead of one per event. - const updateEvents = [ - "audio-state-changed", - "page-title-updated", - "did-finish-load", - "did-start-loading", - "did-stop-loading", - "media-started-playing", - "media-paused", - "did-start-navigation", - "did-redirect-navigation", - "did-navigate-in-page" - ] as const; - - for (const eventName of updateEvents) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - webContents.on(eventName as any, () => { - this.scheduleUpdateTabState(); - }); - } - } - - /** - * Sets up the window open handler on the webContents. - * Auto-cleans when webContents is destroyed. - * Called from initializeView(). - */ - private setupWindowOpenHandler() { - const webContents = this.webContents!; - - // Set window open handler — emit event instead of calling controller directly - webContents.setWindowOpenHandler((handlerDetails) => { - switch (handlerDetails.disposition) { - case "foreground-tab": - case "background-tab": - case "new-window": { - return { - action: "allow", - outlivesOpener: true, - createWindow: (constructorOptions) => { - // For background-tab disposition (middle-click), Electron does NOT provide - // a pre-created webContents - we need to load the URL manually. - // For foreground-tab/new-window, Electron may provide one. - // This is a bit of a hack, may break on future Electron versions. - const viewOptions = constructorOptions as Electron.WebContentsViewConstructorOptions; - const needsManualLoad = !viewOptions.webContents; - - // Emit event for the controller to handle - this.emit( - "new-tab-requested", - handlerDetails.url, - handlerDetails.disposition, - viewOptions, - handlerDetails, - { noLoadURL: !needsManualLoad } - ); - // The controller will create the tab and return its webContents - // via a synchronous callback pattern - return this._lastCreatedWebContents!; - } - }; - } - default: - return { action: "allow" }; - } - }); - } - - // --- State Updates --- - - /** - * Updates a single state property with change detection. - * Emits "updated" with the changed property key. - * Does NOT trigger persistence directly — the controller listens for "updated". - */ - public updateStateProperty(property: T, newValue: this[T]) { - if (this.isDestroyed) return false; - - const currentValue = this[property]; - if (currentValue === newValue) return false; - - this[property] = newValue; - this.emit("updated", [property]); - return true; - } - - /** - * Schedules an updateTabState() call via queueMicrotask. - * Multiple calls within the same event-loop tick are coalesced into one, - * dramatically reducing redundant work during page loads where several - * webContents events fire in rapid succession. - */ - public scheduleUpdateTabState() { - if (this._updatePending) return; - this._updatePending = true; - queueMicrotask(() => { - this._updatePending = false; - this.updateTabState(); - }); - } - - /** - * Reads current state from webContents and emits "updated" if anything changed. - * Uses a smarter nav history comparison (length + index check first) - * instead of JSON.stringify on every call. - */ - public updateTabState() { - if (this.isDestroyed) return false; - if (this.asleep) return false; - if (!this.webContents) return false; - - const { webContents } = this; - const changedKeys: TabContentProperty[] = []; - - const newTitle = webContents.getTitle(); - if (newTitle !== this.title) { - this.title = newTitle; - changedKeys.push("title"); - } - - const newUrl = webContents.getURL(); - if (newUrl !== this.url) { - this.url = newUrl; - changedKeys.push("url"); - } - - const newIsLoading = webContents.isLoading(); - if (newIsLoading !== this.isLoading) { - this.isLoading = newIsLoading; - changedKeys.push("isLoading"); - } - - const newAudible = webContents.isCurrentlyAudible(); - if (newAudible !== this.audible) { - this.audible = newAudible; - changedKeys.push("audible"); - } - - const newMuted = webContents.isAudioMuted(); - if (newMuted !== this.muted) { - this.muted = newMuted; - changedKeys.push("muted"); - } - - // Smart nav history comparison: - // - fast path on length/index changes - // - fallback active-entry check for in-place mutations - // (e.g. replaceState updates where length/index stay the same) - const newNavHistory = webContents.navigationHistory.getAllEntries(); - const newNavHistoryIndex = webContents.navigationHistory.getActiveIndex(); - - const lengthChanged = newNavHistory.length !== this.lastNavHistoryLength; - const indexChanged = newNavHistoryIndex !== this.lastNavHistoryIndex; - let activeEntryChanged = false; - - if (!lengthChanged && !indexChanged) { - const oldActiveEntry = this.navHistory[this.navHistoryIndex]; - const newActiveEntry = newNavHistory[newNavHistoryIndex]; - - activeEntryChanged = - (oldActiveEntry?.url ?? "") !== (newActiveEntry?.url ?? "") || - (oldActiveEntry?.title ?? "") !== (newActiveEntry?.title ?? ""); - } - - if (lengthChanged || indexChanged || activeEntryChanged) { - this.navHistory = newNavHistory; - this.navHistoryIndex = newNavHistoryIndex; - this.lastNavHistoryLength = newNavHistory.length; - this.lastNavHistoryIndex = newNavHistoryIndex; - changedKeys.push("navHistory"); - - if (indexChanged) { - changedKeys.push("navHistoryIndex"); - } - } - - if (changedKeys.length > 0) { - this.emit("updated", changedKeys); - return true; - } - return false; - } - - // --- View Management --- - - /** - * Removes the view from the current window. - */ - private removeViewFromWindow() { - const oldWindow = this.window; - if (oldWindow && this.layer) { - oldWindow.layerManager.pop(this.layer); - return true; - } - return false; - } - - /** - * Sets the window for the tab and adds the view to it. - * If the tab is sleeping (no view), only updates the window reference. - */ - public setWindow(window: BrowserWindow, layerType: LayerType = "tab") { - const oldWindowId = this.window?.id; - const windowChanged = this.window !== window; - if (windowChanged) { - this.removeViewFromWindow(); - } - - if (window) { - this.window = window; - // Only add view if it exists (sleeping tabs have no view) - if (this.view && this.layer) { - if ( - windowChanged || - this.layer.zIndex !== zIndexes[layerType] || - this.layer.focusPriority !== focusPriorities[layerType] - ) { - window.layerManager.pop(this.layer); - this.layer = new Layer( - window.layerManager, - this.view, - zIndexes[layerType], - focusPriorities[layerType], - createModalTo(layerType) - ); - } - window.layerManager.push(this.layer); - } - } - - if (windowChanged && oldWindowId !== undefined) { - this.emit("window-changed", oldWindowId); - } - } - - /** - * Gets the current window for the tab. - */ - public getWindow() { - return this.window; - } - - /** - * Sets the space for the tab. - */ - public setSpace(spaceId: string) { - if (this.spaceId === spaceId) return; - this.spaceId = spaceId; - this.emit("space-changed"); - } - - // --- Navigation --- - - /** - * Canonical key for “same page” in history (strip hash; YouTube shorts/watch ignore tracking params). - */ - private static historyUrlSessionKey(urlString: string): string { - try { - const u = new URL(urlString); - u.hash = ""; - const host = u.hostname.toLowerCase().replace(/^www\./, ""); - - if (host === "youtube.com" || host === "m.youtube.com" || host === "music.youtube.com") { - const parts = u.pathname.split("/").filter(Boolean); - if (parts[0] === "shorts" && parts[1]) { - return `yt/shorts/${parts[1]}`; - } - if (u.pathname === "/watch" || u.pathname.startsWith("/watch/")) { - const v = u.searchParams.get("v"); - if (v) return `yt/watch/${v}`; - } - } - - if (host === "youtu.be") { - const id = u.pathname.replace(/^\//, "").split("/")[0]; - if (id) return `yt/watch/${id}`; - } - - return u.href; - } catch { - return urlString; - } - } - - /** Canonical form used when matching typed intent to a committed URL. */ - private static canonicalHistoryTypedUrl(urlString: string): string { - try { - return new URL(urlString).toString(); - } catch { - return urlString; - } - } - - /** - * Next successful http(s) history recording increments `typed_count` only if the committed URL - * matches `url` after simple URL canonicalization (for example, origin-only trailing slashes). - */ - public markTypedNavigationForNextHistoryVisit(url: string): void { - this.pendingHistoryTypedUrl = Tab.canonicalHistoryTypedUrl(url); - } - - private clearPendingHistoryTypedNavigation(): void { - this.pendingHistoryTypedUrl = null; - } - - /** Clears pending typed intent; returns whether it applied to this recorded URL. */ - private consumeHistoryTypedPendingForRecordedUrl(recordedUrl: string): boolean { - const pending = this.pendingHistoryTypedUrl; - this.pendingHistoryTypedUrl = null; - if (pending === null) return false; - return pending === Tab.canonicalHistoryTypedUrl(recordedUrl); - } - - /** - * Clear in-memory duplicate suppression after history rows are removed so the same page can be - * recorded again without forcing the tab through another URL first. - */ - public clearBrowsingHistoryDeduping(url?: string): void { - if (!url) { - this.lastRecordedHistoryKey = ""; - return; - } - - const clearedKey = Tab.historyUrlSessionKey(url); - if (this.lastRecordedHistoryKey === clearedKey) { - this.lastRecordedHistoryKey = ""; - } - } - - /** - * When the tab becomes selected (or is part of the active tab group), record the - * current page if needed. Background/restored tabs do not write history until then. - */ - public recordBrowsingHistoryOnActivationIfNeeded(): void { - if (this.isDestroyed || !this.webContents) return; - if (!this.tabsController.isTabActive(this)) return; - this.recordBrowsingHistoryFromWebContents(this.webContents); - } - - private recordBrowsingHistoryFromWebContents(webContents: WebContents, urlOverride?: string): void { - const url = urlOverride ?? webContents.getURL(); - - if (!this.tabsController.isTabActive(this)) return; - - // A freshly activated tab can still be sitting on the transient blank page while its first - // real navigation is in flight. Let the committed navigation handle history/typed intent. - if ((url === "" || url === "about:blank") && webContents.isLoading()) return; - - if (!isHistoryRecordableUrl(url) || this.loadedProfile.profileData.ephemeral) { - this.clearPendingHistoryTypedNavigation(); - return; - } - - const sessionKey = Tab.historyUrlSessionKey(url); - if (sessionKey === this.lastRecordedHistoryKey && this.lastRecordedHistoryKey !== "") { - this.consumeHistoryTypedPendingForRecordedUrl(url); - return; - } - - const incrementTyped = this.consumeHistoryTypedPendingForRecordedUrl(url); - this.lastRecordedHistoryKey = sessionKey; - - recordBrowsingHistoryVisit({ - profileId: this.profileId, - url, - title: webContents.getTitle(), - incrementTyped - }); - } - - /** - * Loads a URL in the tab. - */ - - public loadURL(url: string, replace?: boolean) { - if (!this.webContents) return; - - if (replace) { - const sanitizedUrl = JSON.stringify(url); - this.webContents.executeJavaScript(`window.location.replace(${sanitizedUrl})`); - } else { - this.webContents.loadURL(url); - } - } - - /** - * Loads an error page in the tab. - */ - public loadErrorPage(errorCode: number, url: string) { - const parsedURL = URL.parse(url); - if (parsedURL && parsedURL.protocol === "flow:" && parsedURL.hostname === "error") { - return; // Prevent infinite error page loop - } - - const errorPageURL = new URL("flow://error"); - errorPageURL.searchParams.set("errorCode", errorCode.toString()); - errorPageURL.searchParams.set("url", url); - errorPageURL.searchParams.set("initial", "1"); - - const replace = FLAGS.ERROR_PAGE_LOAD_MODE === "replace"; - this.loadURL(errorPageURL.toString(), replace); - } - - // --- Destruction --- - - /** - * Destroys the tab and cleans up resources. - * Does NOT handle persistence cleanup — the controller does that - * by listening to "destroyed". - */ - public destroy() { - if (this.isDestroyed) return; - - this.sendTargetUrlToRenderer(""); - - this.isDestroyed = true; - this.emit("destroyed"); - - this.removeViewFromWindow(); - - if (this.webContents && !this.webContents.isDestroyed()) { - this.webContents.close(); - } - - // Note: fullscreen cleanup is handled by TabLifecycleManager.onDestroy() - - this.destroyEmitter(); - } -} diff --git a/src/main/controllers/windows-controller/layer-manager/index.ts b/src/main/controllers/windows-controller/layer-manager/index.ts index 350a6f550..96ffabe9a 100644 --- a/src/main/controllers/windows-controller/layer-manager/index.ts +++ b/src/main/controllers/windows-controller/layer-manager/index.ts @@ -49,6 +49,7 @@ export class Layer { } this.view.webContents.focus(); + this.manager.clearPendingFocusReallocation(); return true; } return false; @@ -94,15 +95,30 @@ type LayerManagerEvents = { export class LayerManager extends TypedEventEmitter { private readonly parentView: Electron.View; + private readonly browserWindow: Electron.BrowserWindow; private layers: Layer[] = []; private oldLayers: Layer[] = []; private readonly layersWithDestroyListener = new WeakSet(); + // Deferred focus reallocation: when reallocateFocus is called while the + // window is NOT focused, we defer until the window regains focus. This + // prevents webContents.focus() from stealing OS focus to a background window. + private _focusReallocatePending = false; + constructor(window: BrowserWindow) { super(); this.parentView = window.browserWindow.contentView; + this.browserWindow = window.browserWindow; + + // When the window gains focus, run deferred focus reallocation (once) + this.browserWindow.on("focus", () => { + if (this._focusReallocatePending) { + this._focusReallocatePending = false; + this.reallocateFocus(); + } + }); } /** @@ -162,8 +178,19 @@ export class LayerManager extends TypedEventEmitter { /** * The focused layer is no longer there, so we need to find a new one to focus. + * If the window is not currently focused, defers until it regains focus to avoid + * stealing OS focus from the active window via webContents.focus(). */ public reallocateFocus() { + if (this.browserWindow.isDestroyed()) return; + + if (!this.browserWindow.isFocused()) { + this._focusReallocatePending = true; + return; + } + + this._focusReallocatePending = false; + const layers = this.layers .filter((layer) => layer.isVisible()) .toSorted((a, b) => b.focusPriority - a.focusPriority); @@ -175,6 +202,10 @@ export class LayerManager extends TypedEventEmitter { } } + public clearPendingFocusReallocation(): void { + this._focusReallocatePending = false; + } + public getFocusedLayer(): Layer | null { return this.layers.find((layer) => layer.isFocused()) ?? null; } diff --git a/src/main/controllers/windows-controller/types/browser.ts b/src/main/controllers/windows-controller/types/browser.ts index 12ef45798..67d52d50f 100644 --- a/src/main/controllers/windows-controller/types/browser.ts +++ b/src/main/controllers/windows-controller/types/browser.ts @@ -9,13 +9,14 @@ import { Omnibox } from "@/controllers/windows-controller/utils/browser/omnibox" import { initializePortalComponentWindows } from "@/controllers/windows-controller/utils/browser/portal-component-windows"; import { sendMessageToListenersWithWebContents } from "@/ipc/listeners-manager"; import { fireWindowStateChanged } from "@/ipc/browser/interface"; -import { tabsController } from "@/controllers/tabs-controller"; +import { tabService } from "@/services/tab-service"; import { sessionsController } from "@/controllers/sessions-controller"; import { spacesController } from "@/controllers/spaces-controller"; -import { tabPersistenceManager } from "@/saving/tabs"; +import { tabPersistenceService } from "@/services/tab-service"; +import { relocateTabsFromClosingWindow } from "@/services/tab-service/tab-sync"; import { quitController } from "@/controllers/quit-controller"; import { hex_is_light } from "@/modules/utils"; -import { relocateTabsFromClosingWindow } from "@/controllers/tabs-controller/tab-sync"; + import { createModalTo, focusPriorities, zIndexes } from "~/layers"; import { SidebarInterpolation } from "@/controllers/windows-controller/utils/browser/sidebar-interpolation"; import { SIDEBAR_ANIMATION_DURATION_MS } from "~/flow/sidebar-animation"; @@ -153,7 +154,7 @@ export class BrowserWindow extends BaseWindow { if (boundsDebounceTimer) clearTimeout(boundsDebounceTimer); boundsDebounceTimer = setTimeout(() => { const bounds = browserWindow.getBounds(); - tabPersistenceManager.markWindowStateDirty(`w-${this.id}`, { + tabPersistenceService.markWindowStateDirty(`w-${this.id}`, { width: bounds.width, height: bounds.height, x: bounds.x, @@ -338,7 +339,7 @@ export class BrowserWindow extends BaseWindow { public setPageBounds(bounds: PageBounds) { this.pageBounds = bounds; this.emit("page-bounds-changed", bounds); - tabsController.handlePageBoundsChanged(this.id); + tabService.handlePageBoundsChanged(this.id); } /** @@ -378,17 +379,18 @@ export class BrowserWindow extends BaseWindow { this.pageBounds = newBounds; this.emit("page-bounds-changed", newBounds); - tabsController.handlePageBoundsChanged(this.id); + tabService.handlePageBoundsChanged(this.id); } // Current Space // public currentSpaceId: string | null = null; setCurrentSpace(spaceId: string) { + const oldSpaceId = this.currentSpaceId; this.currentSpaceId = spaceId; this.emit("current-space-changed", spaceId); appMenuController.render(); - tabsController.setCurrentWindowSpace(this.id, spaceId); + tabService.setCurrentWindowSpace(this.id, spaceId, oldSpaceId); } // Override Destroy Method to Cleanup Window // @@ -400,20 +402,18 @@ export class BrowserWindow extends BaseWindow { this.sidebarInterpolation = null; } - const closingWindowTabs = tabsController.getTabsInWindow(this.id); - // relocateTabsFromClosingWindow returns null when sync is off or no surviving - // windows exist, otherwise the list of ephemeral tabs that were NOT relocated. - const unrelocatedTabs = !quitController.isQuitting ? relocateTabsFromClosingWindow(this, closingWindowTabs) : null; + const closingWindowTabs = tabService.getTabsInWindow(this.id); const result = super.destroy(...args); if (result) { // Skip during quit — the process is dying and the database is already closed, // so calling tab.destroy() would crash when it tries to access SQLite. - if (!quitController.isQuitting) { - // Determine which tabs still need destruction: - // - null → sync was off / no surviving windows; destroy all tabs - // - array → only the unrelocated (ephemeral) tabs need destroying - const tabsToDestroy = unrelocatedTabs ?? closingWindowTabs; + if (!quitController.isQuitting && closingWindowTabs.length > 0) { + // Try to relocate tabs to surviving windows (when sync is enabled) + const unrelocatable = relocateTabsFromClosingWindow(this, closingWindowTabs); + + // Destroy tabs that couldn't be relocated (or all if sync is disabled) + const tabsToDestroy = unrelocatable ?? closingWindowTabs; if (tabsToDestroy.length > 0) { setTimeout(() => { for (const tab of tabsToDestroy) { @@ -423,6 +423,7 @@ export class BrowserWindow extends BaseWindow { } } + tabService.removeAllLayoutsForWindow(this.id); this.omnibox.destroy(); this.layerManager.destroy(); } diff --git a/src/main/ipc/app/new-tab.ts b/src/main/ipc/app/new-tab.ts index 8411dbd5b..9cc469cdd 100644 --- a/src/main/ipc/app/new-tab.ts +++ b/src/main/ipc/app/new-tab.ts @@ -3,7 +3,7 @@ import { spacesController } from "@/controllers/spaces-controller"; import { ipcMain } from "electron"; import { browserWindowsController } from "@/controllers/windows-controller/interfaces/browser"; import { BrowserWindow } from "@/controllers/windows-controller/types"; -import { tabsController } from "@/controllers/tabs-controller"; +import { tabService } from "@/services/tab-service"; export function openNewTab(window: BrowserWindow) { const omnibox = window.omnibox; @@ -26,8 +26,8 @@ export function openNewTab(window: BrowserWindow) { spacesController.get(spaceId).then(async (space) => { if (!space) return; - const tab = await tabsController.createTab(window.id, space.profileId, spaceId); - tabsController.activateTab(tab); + const tab = tabService.createTabInternal(window.id, space.profileId, spaceId); + tabService.activateTab(tab); }); } } diff --git a/src/main/ipc/browser/find-in-page.ts b/src/main/ipc/browser/find-in-page.ts index 8af6ac144..8ce68ce2f 100644 --- a/src/main/ipc/browser/find-in-page.ts +++ b/src/main/ipc/browser/find-in-page.ts @@ -1,6 +1,6 @@ import { ipcMain, WebContents } from "electron"; import { browserWindowsController } from "@/controllers/windows-controller/interfaces/browser"; -import { tabsController } from "@/controllers/tabs-controller"; +import { tabService } from "@/services/tab-service"; function getFocusedTabWebContents(senderWebContents: Electron.WebContents) { const window = browserWindowsController.getWindowFromWebContents(senderWebContents); @@ -9,7 +9,7 @@ function getFocusedTabWebContents(senderWebContents: Electron.WebContents) { const spaceId = window.currentSpaceId; if (!spaceId) return null; - const tab = tabsController.getFocusedTab(window.id, spaceId); + const tab = tabService.getFocusedTab(window.id, spaceId); if (!tab?.webContents || tab.webContents.isDestroyed()) return null; return tab.webContents; diff --git a/src/main/ipc/browser/history.ts b/src/main/ipc/browser/history.ts index 37ce5ca27..d4ce13659 100644 --- a/src/main/ipc/browser/history.ts +++ b/src/main/ipc/browser/history.ts @@ -1,6 +1,6 @@ import { browserWindowsController } from "@/controllers/windows-controller/interfaces/browser"; import { spacesController } from "@/controllers/spaces-controller"; -import { tabsController } from "@/controllers/tabs-controller"; +import { tabService } from "@/services/tab-service"; import { clearBrowsingHistoryForProfile, deleteBrowsingUrlRowForProfile, @@ -50,7 +50,7 @@ ipcMain.handle("history:delete-visit", async (event, visitId: number) => { const url = getBrowsingVisitUrlForProfile(profileId, visitId); const deleted = deleteBrowsingVisitForProfile(profileId, visitId); if (deleted) { - tabsController.clearBrowsingHistoryDedupingForProfile(profileId, url ?? undefined); + tabService.clearBrowsingHistoryDedupingForProfile(profileId, url ?? undefined); } return deleted; }); @@ -61,7 +61,7 @@ ipcMain.handle("history:delete-url", async (event, urlRowId: number) => { const url = getBrowsingUrlValueForProfile(profileId, urlRowId); const deleted = deleteBrowsingUrlRowForProfile(profileId, urlRowId); if (deleted) { - tabsController.clearBrowsingHistoryDedupingForProfile(profileId, url ?? undefined); + tabService.clearBrowsingHistoryDedupingForProfile(profileId, url ?? undefined); } return deleted; }); @@ -70,5 +70,5 @@ ipcMain.handle("history:clear-all", async (event) => { const profileId = await profileIdFromSender(event.sender); if (!profileId) return; clearBrowsingHistoryForProfile(profileId); - tabsController.clearBrowsingHistoryDedupingForProfile(profileId); + tabService.clearBrowsingHistoryDedupingForProfile(profileId); }); diff --git a/src/main/ipc/browser/navigation.ts b/src/main/ipc/browser/navigation.ts index a436f435c..ac04ec7fc 100644 --- a/src/main/ipc/browser/navigation.ts +++ b/src/main/ipc/browser/navigation.ts @@ -1,4 +1,4 @@ -import { tabsController } from "@/controllers/tabs-controller"; +import { tabService } from "@/services/tab-service"; import { browserWindowsController } from "@/controllers/windows-controller/interfaces/browser"; import { ipcMain } from "electron"; @@ -10,7 +10,7 @@ ipcMain.on("navigation:go-to", (event, url: string, tabId?: number, typedFromAdd const currentSpace = window.currentSpaceId; if (!currentSpace) return false; - const tab = tabId ? tabsController.getTabById(tabId) : tabsController.getFocusedTab(window.id, currentSpace); + const tab = tabId ? tabService.getTabById(tabId) : tabService.getFocusedTab(window.id, currentSpace); if (!tab) return false; if (typedFromAddressBar === true) { @@ -21,21 +21,21 @@ ipcMain.on("navigation:go-to", (event, url: string, tabId?: number, typedFromAdd }); ipcMain.on("navigation:stop-loading-tab", (_event, tabId: number) => { - const tab = tabsController.getTabById(tabId); + const tab = tabService.getTabById(tabId); if (!tab) return; tab.webContents?.stop(); }); ipcMain.on("navigation:reload-tab", (_event, tabId: number) => { - const tab = tabsController.getTabById(tabId); + const tab = tabService.getTabById(tabId); if (!tab) return; tab.webContents?.reload(); }); ipcMain.handle("navigation:get-tab-status", async (_event, tabId: number) => { - const tab = tabsController.getTabById(tabId); + const tab = tabService.getTabById(tabId); if (!tab) return null; const tabWebContents = tab.webContents; @@ -51,7 +51,7 @@ ipcMain.handle("navigation:get-tab-status", async (_event, tabId: number) => { }); ipcMain.on("navigation:go-to-entry", (_event, tabId: number, index: number) => { - const tab = tabsController.getTabById(tabId); + const tab = tabService.getTabById(tabId); if (!tab) return; return tab.webContents?.navigationHistory?.goToIndex(index); diff --git a/src/main/ipc/browser/pinned-tabs.ts b/src/main/ipc/browser/pinned-tabs.ts deleted file mode 100644 index aeea9bb05..000000000 --- a/src/main/ipc/browser/pinned-tabs.ts +++ /dev/null @@ -1,299 +0,0 @@ -import { pinnedTabsController } from "@/controllers/pinned-tabs-controller"; -import { tabsController } from "@/controllers/tabs-controller"; -import { browserWindowsController } from "@/controllers/windows-controller/interfaces/browser"; -import { BrowserWindow } from "@/controllers/windows-controller/types"; -import { clipboard, ipcMain, Menu, MenuItem } from "electron"; -import { PinnedTabData } from "~/types/pinned-tabs"; -import { moveTabOrGroupToWindow } from "@/controllers/tabs-controller/tab-sync"; - -// --- Change notification --- - -let changeTimeout: NodeJS.Timeout | null = null; - -function schedulePinnedTabsChange() { - if (changeTimeout) clearTimeout(changeTimeout); - changeTimeout = setTimeout(() => { - changeTimeout = null; - const allByProfile = pinnedTabsController.getAllByProfile(); - for (const window of browserWindowsController.getWindows()) { - window.sendMessageToCoreWebContents("pinned-tabs:on-changed", allByProfile); - } - }, 80); -} - -// Listen for changes from the controller -pinnedTabsController.on("changed", () => { - schedulePinnedTabsChange(); -}); - -// --- Wire tab destruction --- -// When a browser tab is destroyed, clear any pinned tab association pointing to it. -tabsController.on("tab-removed", (tab) => { - pinnedTabsController.onBrowserTabDestroyed(tab.id); -}); - -// NOTE: Pinned tabs are per-profile, but their associated ephemeral tabs live -// in a specific space. We intentionally do NOT move them when switching spaces -// so that each space maintains its own independent active-tab state. The -// associated tab is moved to the current space only when the user explicitly -// clicks the pinned tab (see handlePinnedTabClick). - -// --- Shared helpers --- - -/** - * Create a new ephemeral tab for a pinned tab in a specific space, associate it, and activate it. - */ -async function createAndAssociatePinnedTab( - pinnedTabId: string, - pinnedTab: PinnedTabData, - window: BrowserWindow, - spaceId: string, - url?: string -) { - const newTab = await tabsController.createTab(window.id, pinnedTab.profileId, spaceId, undefined, { - url: url ?? pinnedTab.defaultUrl, - ephemeral: true - }); - - pinnedTabsController.associateTab(pinnedTabId, spaceId, newTab.id); - tabsController.activateTab(newTab); - return newTab; -} - -// --- IPC Handlers --- - -/** - * Get all pinned tabs grouped by profile ID. - */ -ipcMain.handle("pinned-tabs:get-data", async () => { - return pinnedTabsController.getAllByProfile(); -}); - -/** - * Create a pinned tab from an existing browser tab. - * The tab's current URL becomes the pinned tab's defaultUrl. - */ -ipcMain.handle("pinned-tabs:create-from-tab", async (_event, tabId: number, position?: number) => { - const tab = tabsController.getTabById(tabId); - if (!tab) return null; - - const url = tab.url; - if (!url) return null; - - const faviconUrl = tab.faviconURL ?? null; - const pinnedTab = pinnedTabsController.create(tab.profileId, url, faviconUrl, position); - - // Mark the tab as ephemeral so it won't be persisted across sessions - tabsController.makeTabEphemeral(tab.id); - - // Associate the pinned tab with the browser tab in its current space - pinnedTabsController.associateTab(pinnedTab.uniqueId, tab.spaceId, tab.id); - - return { ...pinnedTab, associatedTabIdsBySpace: { [tab.spaceId]: tab.id } }; -}); - -/** - * Click handler: activate or create the associated browser tab for the current space. - * If the pinned tab already has an associated live tab in the current space, switch to it. - * Otherwise, create a new tab with the pinned tab's defaultUrl in the current space. - * - * When navigateToDefault is true (double-click), also navigates the - * associated tab back to the pinned tab's defaultUrl first. - */ -async function handlePinnedTabClick( - window: BrowserWindow, - pinnedTabId: string, - navigateToDefault: boolean -): Promise { - const pinnedTab = pinnedTabsController.getById(pinnedTabId); - if (!pinnedTab) return false; - - // Get the current space ID - const currentSpaceId = window.currentSpaceId; - if (!currentSpaceId) return false; - - // Check if there's already an associated tab for this space - const associatedTabId = pinnedTabsController.getAssociatedTabId(pinnedTabId, currentSpaceId); - - if (associatedTabId !== null) { - const tab = tabsController.getTabById(associatedTabId); - if (tab && !tab.isDestroyed) { - // Move to the requesting window if needed - if (tab.getWindow().id !== window.id) { - await moveTabOrGroupToWindow(tab, window); - } - - if (navigateToDefault && tab.url !== pinnedTab.defaultUrl) { - tab.loadURL(pinnedTab.defaultUrl); - } - tabsController.activateTab(tab); - return true; - } - // Tab was destroyed but association wasn't cleaned up — clear it - pinnedTabsController.dissociateTab(pinnedTabId, currentSpaceId); - } - - // No associated tab for this space — create a new one - const newTab = await createAndAssociatePinnedTab(pinnedTabId, pinnedTab, window, currentSpaceId); - return newTab !== null; -} - -ipcMain.handle("pinned-tabs:click", async (event, pinnedTabId: string) => { - const window = browserWindowsController.getWindowFromWebContents(event.sender); - if (!window) return false; - return handlePinnedTabClick(window, pinnedTabId, false); -}); - -ipcMain.handle("pinned-tabs:double-click", async (event, pinnedTabId: string) => { - const window = browserWindowsController.getWindowFromWebContents(event.sender); - if (!window) return false; - return handlePinnedTabClick(window, pinnedTabId, true); -}); - -/** - * Remove a pinned tab. - * Also destroys all associated ephemeral tabs (if any) so they don't leak. - */ -ipcMain.handle("pinned-tabs:remove", async (_event, pinnedTabId: string) => { - const removedTabIds = pinnedTabsController.remove(pinnedTabId); - for (const tabId of removedTabIds) { - const tab = tabsController.getTabById(tabId); - if (tab && !tab.isDestroyed) { - tab.destroy(); - } - } - return true; -}); - -/** - * Unpin a tab back to the tab list in the current space. - * Removes the association for the current space and makes that tab persistent - * so it reappears in the sidebar at the given position. - * If there is no associated tab in the current space, creates a new persistent tab. - */ -ipcMain.handle("pinned-tabs:unpin-to-tab-list", async (event, pinnedTabId: string, position?: number) => { - const webContents = event.sender; - const window = browserWindowsController.getWindowFromWebContents(webContents); - if (!window) return false; - - const currentSpaceId = window.currentSpaceId; - if (!currentSpaceId) return false; - - const pinnedTab = pinnedTabsController.getById(pinnedTabId); - if (!pinnedTab) return false; - - // Get the associated tab for the current space - const associatedTabId = pinnedTabsController.getAssociatedTabId(pinnedTabId, currentSpaceId); - - let preservedTabId: number | null = null; - - // Make the associated tab persistent so it reappears in the sidebar - if (associatedTabId !== null) { - const tab = tabsController.getTabById(associatedTabId); - if (tab && position !== undefined) { - tab.updateStateProperty("position", position); - } - tabsController.makeTabPersistent(associatedTabId); - if (tab) { - preservedTabId = tab.id; - tabsController.normalizePositions(tab.getWindow().id, tab.spaceId); - } - } else { - // No associated tab in this space — create a new persistent tab with the defaultUrl - const newTab = await tabsController.createTab(window.id, pinnedTab.profileId, currentSpaceId, undefined, { - url: pinnedTab.defaultUrl, - position - }); - - tabsController.activateTab(newTab); - tabsController.normalizePositions(window.id, currentSpaceId); - } - - // Remove the pinned-tab record after the live tab has been restored to the - // regular tab list. This keeps unpinning aligned with the remove/unpin - // behavior used elsewhere in the feature. - const removedTabIds = pinnedTabsController.remove(pinnedTabId); - for (const tabId of removedTabIds) { - if (tabId === preservedTabId) continue; - const tab = tabsController.getTabById(tabId); - if (tab && !tab.isDestroyed) { - tab.destroy(); - } - } - - return true; -}); - -/** - * Reorder a pinned tab to a new position. - */ -ipcMain.handle("pinned-tabs:reorder", async (_event, pinnedTabId: string, newPosition: number) => { - pinnedTabsController.reorder(pinnedTabId, newPosition); - return true; -}); - -/** - * Show the context menu for a pinned tab. - */ -ipcMain.on("pinned-tabs:show-context-menu", (event, pinnedTabId: string) => { - const webContents = event.sender; - const window = browserWindowsController.getWindowFromWebContents(webContents); - if (!window) return; - - const pinnedTab = pinnedTabsController.getById(pinnedTabId); - if (!pinnedTab) return; - - const contextMenu = new Menu(); - - contextMenu.append( - new MenuItem({ - label: "Unpin", - click: () => { - const removedTabIds = pinnedTabsController.remove(pinnedTabId); - for (const tabId of removedTabIds) { - const tab = tabsController.getTabById(tabId); - if (tab && !tab.isDestroyed) { - tab.destroy(); - } - } - } - }) - ); - - contextMenu.append( - new MenuItem({ - type: "separator" - }) - ); - - // "Reset to Default" — navigate associated tab in current space back to defaultUrl - const currentSpaceId = window.currentSpaceId; - const associatedTabId = currentSpaceId ? pinnedTabsController.getAssociatedTabId(pinnedTabId, currentSpaceId) : null; - const associatedTab = associatedTabId !== null ? tabsController.getTabById(associatedTabId) : undefined; - const isOnDifferentUrl = associatedTab && associatedTab.url !== pinnedTab.defaultUrl; - - contextMenu.append( - new MenuItem({ - label: "Reset to Default", - enabled: !!isOnDifferentUrl, - click: () => { - if (associatedTab && !associatedTab.isDestroyed) { - associatedTab.loadURL(pinnedTab.defaultUrl); - } - } - }) - ); - - contextMenu.append( - new MenuItem({ - label: "Copy URL", - click: () => { - clipboard.writeText(pinnedTab.defaultUrl); - } - }) - ); - - contextMenu.popup({ - window: window.browserWindow - }); -}); diff --git a/src/main/ipc/browser/prompts/page.ts b/src/main/ipc/browser/prompts/page.ts index 31c8e055e..cdfc310fb 100644 --- a/src/main/ipc/browser/prompts/page.ts +++ b/src/main/ipc/browser/prompts/page.ts @@ -1,4 +1,4 @@ -import { tabsController } from "@/controllers/tabs-controller"; +import { tabService } from "@/services/tab-service"; import { queuePrompt } from "@/modules/prompts"; import { ipcMain } from "electron"; import type { PromptResult, PromptState } from "~/types/prompts"; @@ -24,7 +24,7 @@ async function processPromptRequest( const webContents = event.sender; const webFrame = event.senderFrame; - const tabId = tabsController.getTabByWebContents(webContents)?.id ?? null; + const tabId = tabService.getTabByWebContents(webContents)?.id ?? null; if (!tabId || !webFrame) { // not a tab, return null event.returnValue = failedValue; diff --git a/src/main/ipc/browser/tabs.ts b/src/main/ipc/browser/tabs.ts deleted file mode 100644 index d207074d3..000000000 --- a/src/main/ipc/browser/tabs.ts +++ /dev/null @@ -1,527 +0,0 @@ -import { BaseTabGroup, TabGroup } from "@/controllers/tabs-controller/tab-groups"; -import { spacesController } from "@/controllers/spaces-controller"; -import { clipboard, ipcMain, Menu, MenuItem } from "electron"; -import { TabData, WindowActiveTabIds, WindowFocusedTabIds } from "~/types/tabs"; -import { browserWindowsController } from "@/controllers/windows-controller/interfaces/browser"; -import { BrowserWindow } from "@/controllers/windows-controller/types"; -import { Tab } from "@/controllers/tabs-controller/tab"; -import { tabsController } from "@/controllers/tabs-controller"; -import { restoreRecentlyClosedTabInWindow } from "@/controllers/tabs-controller/recently-closed"; -import { serializeTabForRenderer, serializeTabGroupForRenderer } from "@/saving/tabs/serialization"; -import { recentlyClosedManager } from "@/controllers/tabs-controller/recently-closed-manager"; -import { - isTabSyncEnabled, - isSyncExcludedTab, - moveTabOrGroupToWindow, - runTabSyncMutation -} from "@/controllers/tabs-controller/tab-sync"; - -// IPC Handlers // -function getWindowTabsData(window: BrowserWindow) { - const windowId = window.id; - const syncEnabled = isTabSyncEnabled(); - - // When sync is enabled, return all tabs across all windows EXCEPT - // internal-profile tabs and popup-window tabs that belong to other windows - // (those stay private). Popup windows themselves are not part of sync. - let tabs: Tab[]; - let tabGroups: TabGroup[]; - - if (syncEnabled && window.browserWindowType === "normal") { - tabs = [...tabsController.tabs.values()].filter((tab) => { - if (tab.getWindow().id === windowId) return true; - return !isSyncExcludedTab(tab); - }); - // Include tab groups that still have at least one visible tab - const visibleTabIds = new Set(tabs.map((t) => t.id)); - tabGroups = [...tabsController.tabGroups.values()].filter((group) => - group.tabs.some((t) => visibleTabIds.has(t.id)) - ); - } else { - tabs = tabsController.getTabsInWindow(windowId); - tabGroups = tabsController.getTabGroupsInWindow(windowId); - } - - const tabDatas = tabs.map((tab) => { - const managers = tabsController.getTabManagers(tab.id); - return serializeTabForRenderer(tab, managers?.lifecycle.preSleepState); - }); - const tabGroupDatas = tabGroups.map((tabGroup) => serializeTabGroupForRenderer(tabGroup)); - - const windowProfiles: string[] = []; - const windowSpaces: string[] = []; - - for (const tab of tabs) { - if (!windowProfiles.includes(tab.profileId)) { - windowProfiles.push(tab.profileId); - } - if (!windowSpaces.includes(tab.spaceId)) { - windowSpaces.push(tab.spaceId); - } - } - - const focusedTabs: WindowFocusedTabIds = {}; - const activeTabs: WindowActiveTabIds = {}; - - for (const spaceId of windowSpaces) { - const focusedTab = tabsController.getFocusedTab(windowId, spaceId); - if (focusedTab) { - focusedTabs[spaceId] = focusedTab.id; - } - - const activeTab = tabsController.getActiveTab(windowId, spaceId); - if (activeTab) { - if (activeTab instanceof BaseTabGroup) { - activeTabs[spaceId] = activeTab.tabs.map((tab) => tab.id); - } else { - activeTabs[spaceId] = [activeTab.id]; - } - } - } - - return { - tabs: tabDatas, - tabGroups: tabGroupDatas, - focusedTabIds: focusedTabs, - activeTabIds: activeTabs - }; -} - -ipcMain.handle("tabs:get-data", async (event) => { - const webContents = event.sender; - const window = browserWindowsController.getWindowFromWebContents(webContents); - if (!window) return null; - - return getWindowTabsData(window); -}); - -// --- Tab change queues --- -// -// Two queues track pending IPC updates: -// -// 1. Structural changes (tab created/removed, active tab changed, space changed) -// require a full WindowTabsData refresh because the tab list, groups, -// focused/active maps may all have changed. -// -// 2. Content changes (title, url, isLoading, audible, etc.) only affect -// individual tabs. For these, we serialize just the changed tabs and send -// a lightweight "tabs:on-tabs-content-updated" message instead of the -// full data set. -// -// If a structural change occurs during the debounce window, it absorbs any -// pending content changes for that window (the full refresh includes them). - -const DEBOUNCE_MS = 80; - -/** Windows that need a full data refresh (structural change). */ -const structuralQueue: Set = new Set(); - -/** Windows → set of tab IDs with content-only changes. */ -const contentQueue: Map> = new Map(); - -let queueTimeout: NodeJS.Timeout | null = null; - -function scheduleQueueProcessing() { - if (queueTimeout) return; // already scheduled - queueTimeout = setTimeout(() => { - processQueues(); - queueTimeout = null; - }, DEBOUNCE_MS); -} - -function processQueues() { - // --- Structural changes (full refresh) --- - for (const windowId of structuralQueue) { - const window = browserWindowsController.getWindowById(windowId); - if (!window) continue; - - const data = getWindowTabsData(window); - if (!data) continue; - - window.sendMessageToCoreWebContents("tabs:on-data-changed", data); - - // Content changes for this window are absorbed by the full refresh - contentQueue.delete(windowId); - } - structuralQueue.clear(); - - // --- Content-only changes (lightweight per-tab updates) --- - for (const [windowId, tabIds] of contentQueue) { - const window = browserWindowsController.getWindowById(windowId); - if (!window) continue; - - const updatedTabs: TabData[] = []; - for (const tabId of tabIds) { - const tab = tabsController.getTabById(tabId); - if (!tab) continue; - - const managers = tabsController.getTabManagers(tabId); - updatedTabs.push(serializeTabForRenderer(tab, managers?.lifecycle.preSleepState)); - } - - if (updatedTabs.length > 0) { - window.sendMessageToCoreWebContents("tabs:on-tabs-content-updated", updatedTabs); - } - } - contentQueue.clear(); -} - -/** - * Enqueue a structural change for a window. - * The next queue processing will send a full WindowTabsData refresh. - * When tab sync is enabled, all browser windows are notified. - */ -export function windowTabsChanged(windowId: number) { - if (isTabSyncEnabled()) { - // Broadcast to every browser window - for (const win of browserWindowsController.getWindows()) { - structuralQueue.add(win.id); - } - } else { - structuralQueue.add(windowId); - } - scheduleQueueProcessing(); -} - -/** - * Enqueue a content-only change for a single tab. - * If no structural change occurs before processing, only the changed tabs' - * data will be serialized and sent — much cheaper than a full refresh. - * When tab sync is enabled, the change is enqueued for all browser windows. - */ -export function windowTabContentChanged(windowId: number, tabId: number) { - let targetWindowIds: number[]; - - if (isTabSyncEnabled()) { - // Internal-profile and popup-window tabs are not synced — only notify the owning window - const tab = tabsController.getTabById(tabId); - if (tab && isSyncExcludedTab(tab)) { - targetWindowIds = [windowId]; - } else { - targetWindowIds = browserWindowsController.getWindows().map((w) => w.id); - } - } else { - targetWindowIds = [windowId]; - } - - for (const targetId of targetWindowIds) { - // If a structural change is already pending for this window, skip — - // the full refresh will include this tab's changes. - if (structuralQueue.has(targetId)) continue; - - let tabIds = contentQueue.get(targetId); - if (!tabIds) { - tabIds = new Set(); - contentQueue.set(targetId, tabIds); - } - tabIds.add(tabId); - } - - scheduleQueueProcessing(); -} - -ipcMain.handle("tabs:switch-to-tab", async (event, tabId: number) => { - const webContents = event.sender; - const window = browserWindowsController.getWindowFromWebContents(webContents); - if (!window) return false; - - const tab = tabsController.getTabById(tabId); - if (!tab) return false; - - if (isTabSyncEnabled()) { - let switched = false; - await runTabSyncMutation(async () => { - if (window.destroyed) return; - const currentTab = tabsController.getTabById(tabId); - if (!currentTab || currentTab.isDestroyed) return; - - // In sync mode, the tab may currently live in a different window. - // Move it (and its group) to the requesting window before activating. - // This also creates a screenshot placeholder in the old window. - if (currentTab.getWindow().id !== window.id) { - await moveTabOrGroupToWindow(currentTab, window); - } - - // Re-validate after the async move: the tab or window may have been - // destroyed, or the move may have silently bailed out. - const movedTab = tabsController.getTabById(tabId); - if (!movedTab || movedTab.isDestroyed) return; - if (window.destroyed) return; - if (movedTab.getWindow().id !== window.id) return; - - tabsController.activateTab(movedTab); - switched = true; - }); - return switched; - } - - tabsController.activateTab(tab); - return true; -}); - -ipcMain.handle( - "tabs:new-tab", - async (event, url?: string, isForeground?: boolean, spaceId?: string, typedFromAddressBar?: boolean) => { - const webContents = event.sender; - const window = - browserWindowsController.getWindowFromWebContents(webContents) || browserWindowsController.getWindows()[0]; - if (!window) return; - - if (!spaceId) { - const currentSpace = window.currentSpaceId; - if (!currentSpace) return; - - spaceId = currentSpace; - } - - if (!spaceId) return; - - const space = await spacesController.get(spaceId); - if (!space) return; - - const tab = await tabsController.createTab(window.id, space.profileId, spaceId, undefined, { - url: url || undefined, - typedNavigation: typedFromAddressBar === true - }); - - if (isForeground) { - tabsController.activateTab(tab); - } - return true; - } -); - -ipcMain.handle("tabs:close-tab", async (event, tabId: number) => { - const webContents = event.sender; - const window = browserWindowsController.getWindowFromWebContents(webContents); - if (!window) return false; - - const tab = tabsController.getTabById(tabId); - if (!tab) return false; - - tab.destroy(); - return true; -}); - -ipcMain.handle("tabs:disable-picture-in-picture", async (event, goBackToTab: boolean) => { - const sender = event.sender; - const tab = tabsController.getTabByWebContents(sender); - if (!tab) return false; - - const disabled = tabsController.disablePictureInPicture(tab.id, goBackToTab); - return disabled; -}); - -ipcMain.handle("tabs:set-tab-muted", async (_event, tabId: number, muted: boolean) => { - const tab = tabsController.getTabById(tabId); - if (!tab) return false; - - tab.webContents?.setAudioMuted(muted); - - // No event for mute state change, so we need to update the tab state manually - tab.updateTabState(); - return true; -}); - -ipcMain.handle("tabs:move-tab", async (event, tabId: number, newPosition: number) => { - const webContents = event.sender; - const window = browserWindowsController.getWindowFromWebContents(webContents); - if (!window) return false; - - const tab = tabsController.getTabById(tabId); - if (!tab) return false; - - let targetTabs: Tab[] = [tab]; - - const tabGroup = tabsController.getTabGroupByTabId(tab.id); - if (tabGroup) { - targetTabs = tabGroup.tabs; - } - - for (const targetTab of targetTabs) { - targetTab.updateStateProperty("position", newPosition); - } - - // Normalize positions after reorder to prevent drift - tabsController.normalizePositions(window.id, tab.spaceId); - - return true; -}); - -ipcMain.handle("tabs:move-tab-to-window-space", async (event, tabId: number, spaceId: string, newPosition?: number) => { - const webContents = event.sender; - const window = browserWindowsController.getWindowFromWebContents(webContents); - if (!window) return false; - - const tab = tabsController.getTabById(tabId); - if (!tab) return false; - - const space = await spacesController.get(spaceId); - if (!space) return false; - - // Capture source space before move (for normalizing after) - const sourceSpaceId = tab.spaceId; - - // Collect all tabs to move (includes tab group members) - let targetTabs: Tab[] = [tab]; - const tabGroup = tabsController.getTabGroupByTabId(tab.id); - if (tabGroup) { - targetTabs = tabGroup.tabs; - } - - // Move all tabs in the group to the new space - for (const targetTab of targetTabs) { - targetTab.setSpace(spaceId); - targetTab.setWindow(window); - - if (newPosition !== undefined) { - targetTab.updateStateProperty("position", newPosition); - } - } - - // Normalize positions in both source and target spaces - tabsController.normalizePositions(window.id, spaceId); - if (sourceSpaceId !== spaceId) { - tabsController.normalizePositions(window.id, sourceSpaceId); - } - - tabsController.activateTab(tab); - return true; -}); - -ipcMain.on("tabs:show-context-menu", (event, tabId: number) => { - const webContents = event.sender; - const window = browserWindowsController.getWindowFromWebContents(webContents); - if (!window) return; - - const tab = tabsController.getTabById(tabId); - if (!tab) return; - - const isTabVisible = tab.visible; - const hasURL = !!tab.url; - const lifecycleManager = tabsController.getLifecycleManager(tabId); - - const contextMenu = new Menu(); - - contextMenu.append( - new MenuItem({ - label: "Copy URL", - enabled: hasURL, - click: () => { - const url = tab.url; - if (!url) return; - clipboard.writeText(url); - } - }) - ); - - contextMenu.append( - new MenuItem({ - type: "separator" - }) - ); - - contextMenu.append( - new MenuItem({ - label: isTabVisible ? "Cannot put active tab to sleep" : tab.asleep ? "Wake Tab" : "Put Tab to Sleep", - enabled: !isTabVisible, - click: () => { - if (!lifecycleManager) return; - if (tab.asleep) { - lifecycleManager.wakeUp(); - tabsController.activateTab(tab); - } else { - lifecycleManager.putToSleep(); - } - } - }) - ); - - contextMenu.append( - new MenuItem({ - label: "Close Tab", - click: () => { - tab.destroy(); - } - }) - ); - - contextMenu.append( - new MenuItem({ - type: "separator" - }) - ); - - const recentlyClosed = recentlyClosedManager.getAll(); - const hasRecentlyClosed = recentlyClosed.length > 0; - const mostRecent = hasRecentlyClosed ? recentlyClosed[0] : null; - const mostRecentTitle = mostRecent?.tabData.title; - const mostRecentTruncatedTitle = - mostRecentTitle && mostRecentTitle.length > 35 - ? mostRecentTitle.slice(0, 35).trim() + "..." - : mostRecentTitle?.trim(); - - contextMenu.append( - new MenuItem({ - label: mostRecentTruncatedTitle ? `Reopen Closed Tab (${mostRecentTruncatedTitle})` : "Reopen Closed Tab", - enabled: hasRecentlyClosed, - click: () => { - if (!mostRecent) return; - restoreRecentlyClosedTabInWindow(window, mostRecent.tabData.uniqueId).catch((error) => { - console.error("Failed to restore most recent closed tab:", error); - }); - } - }) - ); - - contextMenu.popup({ - window: window.browserWindow - }); -}); - -// --- Recently Closed Tabs --- - -ipcMain.handle("tabs:get-recently-closed", async () => { - return recentlyClosedManager.getAll(); -}); - -ipcMain.handle("tabs:restore-recently-closed", async (event, uniqueId: string) => { - const webContents = event.sender; - const window = browserWindowsController.getWindowFromWebContents(webContents); - if (!window) return false; - - return restoreRecentlyClosedTabInWindow(window, uniqueId); -}); - -ipcMain.handle("tabs:clear-recently-closed", async () => { - recentlyClosedManager.clear(); - return true; -}); - -// --- Batch Tab Move --- - -ipcMain.handle("tabs:batch-move-tabs", async (event, tabIds: number[], spaceId: string, newPositionStart?: number) => { - const webContents = event.sender; - const window = browserWindowsController.getWindowFromWebContents(webContents); - if (!window) return false; - - const space = await spacesController.get(spaceId); - if (!space) return false; - - for (let i = 0; i < tabIds.length; i++) { - const tab = tabsController.getTabById(tabIds[i]); - if (!tab) continue; - - tab.setSpace(spaceId); - tab.setWindow(window); - - if (newPositionStart !== undefined) { - tab.updateStateProperty("position", newPositionStart + i); - } - } - - // Normalize positions after batch reorder to prevent drift - tabsController.normalizePositions(window.id, spaceId); - - return true; -}); diff --git a/src/main/ipc/index.ts b/src/main/ipc/index.ts index 5b235e1cf..5c25bcd40 100644 --- a/src/main/ipc/index.ts +++ b/src/main/ipc/index.ts @@ -7,8 +7,7 @@ import "@/ipc/app/window-controls"; // Browser APIs import "@/ipc/browser/browser"; -import "@/ipc/browser/tabs"; -import "@/ipc/browser/pinned-tabs"; + import "@/ipc/browser/page"; import "@/ipc/browser/navigation"; import "@/ipc/browser/history"; diff --git a/src/main/ipc/webauthn/conditional.ts b/src/main/ipc/webauthn/conditional.ts index 760c7331b..923401220 100644 --- a/src/main/ipc/webauthn/conditional.ts +++ b/src/main/ipc/webauthn/conditional.ts @@ -5,7 +5,7 @@ import { ipcMain, shell, type IpcMainEvent } from "electron"; import type { AssertCredentialErrorCodes, AssertCredentialResult } from "~/types/fido2-types"; import type { ConditionalPasskeyRequest, ConditionalPasskeyRequestState } from "~/types/passkey"; import { getWebauthnAddon } from "@/ipc/webauthn/module"; -import { tabsController } from "@/controllers/tabs-controller"; +import { tabService } from "@/services/tab-service"; interface PendingConditionalMediation { publicKeyRequestOptions: PublicKeyCredentialRequestOptions; @@ -166,7 +166,7 @@ ipcMain.on( // crash, WebContents destruction, or top-level navigation wiping child frames). const cancelSubscription = onWebFrameDestroyed(webContents, event.senderFrame, cancelDueToContextLoss); - const tabId = tabsController.getTabByWebContents(webContents)?.id ?? null; + const tabId = tabService.getTabByWebContents(webContents)?.id ?? null; pendingConditionalMediations.set(operationId, { publicKeyRequestOptions, diff --git a/src/main/modules/flags.ts b/src/main/modules/flags.ts index e494c03b2..7983c7623 100644 --- a/src/main/modules/flags.ts +++ b/src/main/modules/flags.ts @@ -12,6 +12,7 @@ type Flags = { GLANCE_ENABLED: boolean; FAVICONS_REMOVE_PATH: boolean; INCOGNITO_ENABLED: boolean; + ACTIVATE_TAB_ON_SPACE_SWITCH: boolean; }; export const FLAGS: Flags = { @@ -40,5 +41,8 @@ export const FLAGS: Flags = { FAVICONS_REMOVE_PATH: true, // Incognito: Enable incognito windows - INCOGNITO_ENABLED: true + INCOGNITO_ENABLED: true, + + // Tab Service: Auto-activate the most recently active tab when switching spaces + ACTIVATE_TAB_ON_SPACE_SWITCH: false }; diff --git a/src/main/saving/db/schema.ts b/src/main/saving/db/schema.ts index 6f92b6061..f111914fc 100644 --- a/src/main/saving/db/schema.ts +++ b/src/main/saving/db/schema.ts @@ -1,5 +1,5 @@ import { sqliteTable, text, integer, index, uniqueIndex } from "drizzle-orm/sqlite-core"; -import { NavigationEntry, TabGroupMode } from "~/types/tabs"; +import { NavigationEntry, TabLayoutNodeMode } from "~/types/tab-service"; // --- Tabs Table --- @@ -31,7 +31,7 @@ export type TabInsert = typeof tabs.$inferInsert; export const tabGroups = sqliteTable("tab_groups", { groupId: text("group_id").primaryKey(), - mode: text("mode").$type>().notNull(), + mode: text("mode").$type>().notNull(), profileId: text("profile_id").notNull(), spaceId: text("space_id").notNull(), tabUniqueIds: text("tab_unique_ids", { mode: "json" }).$type().notNull(), diff --git a/src/main/saving/tabs/index.ts b/src/main/saving/tabs/index.ts deleted file mode 100644 index 346b3ad5d..000000000 --- a/src/main/saving/tabs/index.ts +++ /dev/null @@ -1,424 +0,0 @@ -import { getDb, schema } from "@/saving/db"; -import { getSettingValueById } from "@/saving/settings"; -import { ArchiveTabValueMap, SleepTabValueMap } from "@/modules/basic-settings"; -import { getCurrentTimestamp } from "@/modules/utils"; -import { PersistedTabData, PersistedTabGroupData, PersistedWindowState } from "~/types/tabs"; -import { eq } from "drizzle-orm"; -import { TabRow, TabGroupRow, WindowStateRow, TabInsert, TabGroupInsert, WindowStateInsert } from "@/saving/db/schema"; - -// Flush interval in milliseconds -const FLUSH_INTERVAL_MS = 2000; - -// --- Row <-> Domain Object Converters --- - -function tabRowToPersistedData(row: TabRow): PersistedTabData { - return { - schemaVersion: row.schemaVersion, - uniqueId: row.uniqueId, - createdAt: row.createdAt, - lastActiveAt: row.lastActiveAt, - position: row.position, - profileId: row.profileId, - spaceId: row.spaceId, - windowGroupId: row.windowGroupId, - title: row.title, - url: row.url, - faviconURL: row.faviconUrl, - muted: row.muted, - navHistory: row.navHistory, - navHistoryIndex: row.navHistoryIndex - }; -} - -function persistedDataToTabInsert(data: PersistedTabData): TabInsert { - return { - uniqueId: data.uniqueId, - schemaVersion: data.schemaVersion, - createdAt: data.createdAt, - lastActiveAt: data.lastActiveAt, - position: data.position, - profileId: data.profileId, - spaceId: data.spaceId, - windowGroupId: data.windowGroupId, - title: data.title, - url: data.url, - faviconUrl: data.faviconURL, - muted: data.muted, - navHistory: data.navHistory, - navHistoryIndex: data.navHistoryIndex - }; -} - -function tabGroupRowToPersistedData(row: TabGroupRow): PersistedTabGroupData { - return { - groupId: row.groupId, - mode: row.mode, - profileId: row.profileId, - spaceId: row.spaceId, - tabUniqueIds: row.tabUniqueIds, - glanceFrontTabUniqueId: row.glanceFrontTabUniqueId ?? undefined, - position: row.position - }; -} - -function persistedDataToTabGroupInsert(data: PersistedTabGroupData): TabGroupInsert { - return { - groupId: data.groupId, - mode: data.mode, - profileId: data.profileId, - spaceId: data.spaceId, - tabUniqueIds: data.tabUniqueIds, - glanceFrontTabUniqueId: data.glanceFrontTabUniqueId ?? null, - position: data.position - }; -} - -function windowStateRowToPersistedData(row: WindowStateRow): PersistedWindowState { - return { - width: row.width, - height: row.height, - x: row.x ?? undefined, - y: row.y ?? undefined, - isPopup: row.isPopup ?? undefined - }; -} - -function persistedDataToWindowStateInsert(windowGroupId: string, data: PersistedWindowState): WindowStateInsert { - return { - windowGroupId, - width: data.width, - height: data.height, - x: data.x ?? null, - y: data.y ?? null, - isPopup: data.isPopup ?? null - }; -} - -/** - * Manages persistence of tabs and tab groups to disk. - * - * Key design decisions: - * - Dirty-tracking: only tabs that have changed since the last flush are written - * - Batch flush: all dirty tabs are written in a single transaction every ~2s - * - Tab groups are written immediately since they change infrequently - * - flush() can be called synchronously at quit time to ensure no data is lost - */ -export class TabPersistenceManager { - /** Set of tab uniqueIds that have been modified since last flush */ - private dirtyTabs = new Map(); - - /** Set of tab uniqueIds that have been removed since last flush */ - private removedTabs = new Set(); - - /** Window states that have been modified since last flush */ - private dirtyWindowStates = new Map(); - - /** Periodic flush interval handle */ - private flushInterval: ReturnType | null = null; - - /** Whether the manager has been started */ - private started = false; - - /** - * Start the periodic flush timer. - * Should be called once during app startup. - */ - start(): void { - if (this.started) return; - this.started = true; - - this.flushInterval = setInterval(() => { - this.flush().catch((err) => { - console.error("[TabPersistenceManager] Periodic flush failed:", err); - }); - }, FLUSH_INTERVAL_MS); - } - - /** - * Stop the periodic flush timer and do a final flush. - * Should be called during app shutdown. - */ - async stop(): Promise { - if (this.flushInterval) { - clearInterval(this.flushInterval); - this.flushInterval = null; - } - this.started = false; - await this.flush(); - } - - /** - * Mark a tab as dirty with its current serialized data. - * The data will be written to disk on the next flush cycle. - */ - markDirty(uniqueId: string, data: PersistedTabData): void { - // If the tab was previously marked for removal, cancel that - this.removedTabs.delete(uniqueId); - this.dirtyTabs.set(uniqueId, data); - } - - /** - * Mark a tab for removal from storage. - * The removal will be applied on the next flush cycle. - */ - markRemoved(uniqueId: string): void { - this.dirtyTabs.delete(uniqueId); - this.removedTabs.add(uniqueId); - } - - /** - * Mark a window's state as dirty with its current bounds. - * The data will be written to disk on the next flush cycle. - */ - markWindowStateDirty(windowGroupId: string, state: PersistedWindowState): void { - this.dirtyWindowStates.set(windowGroupId, state); - } - - /** - * Remove a tab from storage immediately. - * Used when we need the removal to happen right away (e.g., archiving). - */ - async removeTab(uniqueId: string): Promise { - this.dirtyTabs.delete(uniqueId); - this.removedTabs.delete(uniqueId); - - const db = getDb(); - db.delete(schema.tabs).where(eq(schema.tabs.uniqueId, uniqueId)).run(); - } - - /** - * Flush all pending changes to disk. - * - Writes all dirty tabs in a single batch - * - Removes all tabs marked for deletion - * - Clears the dirty/removed sets after successful write - */ - async flush(): Promise { - // Snapshot and clear the pending changes so new mutations during flush - // are captured in the next cycle - const dirtyEntries = new Map(this.dirtyTabs); - const removedEntries = new Set(this.removedTabs); - const dirtyWindowEntries = new Map(this.dirtyWindowStates); - this.dirtyTabs.clear(); - this.removedTabs.clear(); - this.dirtyWindowStates.clear(); - - // Skip if nothing to do - if (dirtyEntries.size === 0 && removedEntries.size === 0 && dirtyWindowEntries.size === 0) return; - - const db = getDb(); - - try { - // Use a transaction for atomicity - db.transaction((tx) => { - // Upsert dirty tabs - for (const [, data] of dirtyEntries) { - const insert = persistedDataToTabInsert(data); - tx.insert(schema.tabs) - .values(insert) - .onConflictDoUpdate({ - target: schema.tabs.uniqueId, - set: { - schemaVersion: insert.schemaVersion, - createdAt: insert.createdAt, - lastActiveAt: insert.lastActiveAt, - position: insert.position, - profileId: insert.profileId, - spaceId: insert.spaceId, - windowGroupId: insert.windowGroupId, - title: insert.title, - url: insert.url, - faviconUrl: insert.faviconUrl, - muted: insert.muted, - navHistory: insert.navHistory, - navHistoryIndex: insert.navHistoryIndex - } - }) - .run(); - } - - // Remove deleted tabs - for (const uniqueId of removedEntries) { - tx.delete(schema.tabs).where(eq(schema.tabs.uniqueId, uniqueId)).run(); - } - - // Upsert dirty window states - for (const [windowGroupId, state] of dirtyWindowEntries) { - const insert = persistedDataToWindowStateInsert(windowGroupId, state); - tx.insert(schema.windowStates) - .values(insert) - .onConflictDoUpdate({ - target: schema.windowStates.windowGroupId, - set: { - width: insert.width, - height: insert.height, - x: insert.x, - y: insert.y, - isPopup: insert.isPopup - } - }) - .run(); - } - }); - } catch (error) { - // Requeue snapshot entries so failures are retried on the next flush. - // Preserve newer mutations that may have happened while writes were in flight. - for (const [uniqueId, data] of dirtyEntries) { - if (!this.dirtyTabs.has(uniqueId) && !this.removedTabs.has(uniqueId)) { - this.dirtyTabs.set(uniqueId, data); - } - } - - for (const uniqueId of removedEntries) { - if (!this.dirtyTabs.has(uniqueId)) { - this.removedTabs.add(uniqueId); - } - } - - for (const [windowGroupId, state] of dirtyWindowEntries) { - if (!this.dirtyWindowStates.has(windowGroupId)) { - this.dirtyWindowStates.set(windowGroupId, state); - } - } - - throw error; - } - } - - // --- Load methods (used at startup) --- - - /** - * Load all persisted tabs from storage. - */ - async loadAllTabs(): Promise { - const db = getDb(); - const rows = db.select().from(schema.tabs).all(); - return rows.map(tabRowToPersistedData); - } - - /** - * Load all persisted tab groups from storage. - */ - async loadAllTabGroups(): Promise { - const db = getDb(); - const rows = db.select().from(schema.tabGroups).all(); - return rows.map(tabGroupRowToPersistedData); - } - - /** - * Load all persisted window states from storage. - * Returns a map of windowGroupId -> PersistedWindowState. - * - * Wipes the store after loading so stale entries from closed windows - * don't accumulate. The current session's resize/move handlers will - * re-populate it with fresh data. - */ - async loadAllWindowStates(): Promise> { - const db = getDb(); - const rows = db.select().from(schema.windowStates).all(); - const states = new Map(); - - for (const row of rows) { - states.set(row.windowGroupId, windowStateRowToPersistedData(row)); - } - - // Wipe after loading so closed windows don't leave stale entries - db.delete(schema.windowStates).run(); - - return states; - } - - // --- Tab Group persistence --- - - /** - * Save a tab group to storage immediately. - * Tab groups change infrequently so we don't batch them. - */ - async saveTabGroup(_groupId: string, data: PersistedTabGroupData): Promise { - const db = getDb(); - const insert = persistedDataToTabGroupInsert(data); - - db.insert(schema.tabGroups) - .values(insert) - .onConflictDoUpdate({ - target: schema.tabGroups.groupId, - set: { - mode: insert.mode, - profileId: insert.profileId, - spaceId: insert.spaceId, - tabUniqueIds: insert.tabUniqueIds, - glanceFrontTabUniqueId: insert.glanceFrontTabUniqueId, - position: insert.position - } - }) - .run(); - } - - /** - * Remove a tab group from storage immediately. - */ - async removeTabGroup(groupId: string): Promise { - const db = getDb(); - db.delete(schema.tabGroups).where(eq(schema.tabGroups.groupId, groupId)).run(); - } - - /** - * Wipe all tab groups from storage. - */ - async wipeTabGroups(): Promise { - const db = getDb(); - db.delete(schema.tabGroups).run(); - } - - // --- Storage wipe --- - - /** - * Wipe all tabs and tab groups from storage. - */ - async wipeAll(): Promise { - this.dirtyTabs.clear(); - this.removedTabs.clear(); - this.dirtyWindowStates.clear(); - - const db = getDb(); - db.transaction((tx) => { - tx.delete(schema.tabs).run(); - tx.delete(schema.tabGroups).run(); - tx.delete(schema.windowStates).run(); - }); - } -} - -// Singleton instance -export const tabPersistenceManager = new TabPersistenceManager(); - -// --- Settings-based helpers (re-exported for convenience) --- - -/** - * Determines if a tab should be archived based on its lastActiveAt timestamp - * and the user's archive setting. - */ -export function shouldArchiveTab(lastActiveAt: number): boolean { - const archiveTabAfter = getSettingValueById("archiveTabAfter"); - const archiveTabAfterSeconds = ArchiveTabValueMap[archiveTabAfter as keyof typeof ArchiveTabValueMap]; - - if (typeof archiveTabAfterSeconds !== "number") return false; - - const now = getCurrentTimestamp(); - const diff = now - lastActiveAt; - return diff >= archiveTabAfterSeconds; -} - -/** - * Determines if a tab should be put to sleep based on its lastActiveAt timestamp - * and the user's sleep setting. - */ -export function shouldSleepTab(lastActiveAt: number): boolean { - const sleepTabAfter = getSettingValueById("sleepTabAfter"); - const sleepTabAfterSeconds = SleepTabValueMap[sleepTabAfter as keyof typeof SleepTabValueMap]; - - if (typeof sleepTabAfterSeconds !== "number") return false; - - const now = getCurrentTimestamp(); - const diff = now - lastActiveAt; - return diff >= sleepTabAfterSeconds; -} diff --git a/src/main/saving/tabs/restore.ts b/src/main/saving/tabs/restore.ts index ef098f405..8014113a0 100644 --- a/src/main/saving/tabs/restore.ts +++ b/src/main/saving/tabs/restore.ts @@ -1,22 +1,29 @@ -import { PersistedTabData, PersistedTabGroupData } from "~/types/tabs"; -import { tabPersistenceManager } from "@/saving/tabs"; -import { onSettingsCached } from "@/saving/settings"; -import { tabsController } from "@/controllers/tabs-controller"; +import { tabService, tabPersistenceService } from "@/services/tab-service"; +import { onSettingsCached, getSettingValueById } from "@/saving/settings"; import { browserWindowsController } from "@/controllers/windows-controller/interfaces/browser"; -import { shouldArchiveTab } from "@/saving/tabs"; +import { loadedProfilesController } from "@/controllers/loaded-profiles-controller"; import { app } from "electron"; -import { GlanceTabGroup } from "@/controllers/tabs-controller/tab-groups/glance"; import type { BrowserWindowCreationOptions, BrowserWindowType } from "@/controllers/windows-controller/types/browser"; +import type { PersistedTabData, PersistedTabLayoutNodeData } from "~/types/tab-service"; +import { ArchiveTabValueMap } from "@/modules/basic-settings"; + +function shouldArchiveTab(lastActiveAt: number): boolean { + const archiveAfter = getSettingValueById("archiveTabAfter"); + if (typeof archiveAfter !== "string" || archiveAfter === "never") return false; + const archiveAfterSeconds = ArchiveTabValueMap[archiveAfter as keyof typeof ArchiveTabValueMap]; + if (typeof archiveAfterSeconds !== "number" || !isFinite(archiveAfterSeconds)) return false; + const nowSec = Math.floor(Date.now() / 1000); + return nowSec - lastActiveAt >= archiveAfterSeconds; +} /** - * Loads tabs and tab groups from storage, filters archived ones, - * and restores them into browser windows. + * Loads tabs from storage, filters archived ones, and restores them into browser windows. */ export async function restoreSession(): Promise { await app.whenReady(); await onSettingsCached(); - const tabs = await loadAndFilterTabs(); + const tabs = loadAndFilterTabs(); if (tabs.length > 0) { await createTabsFromPersistedData(tabs); } else { @@ -26,17 +33,13 @@ export async function restoreSession(): Promise { return true; } -/** - * Loads tabs from storage and filters out archived ones. - */ -async function loadAndFilterTabs(): Promise { - const allTabs = await tabPersistenceManager.loadAllTabs(); +function loadAndFilterTabs(): PersistedTabData[] { + const allTabs = tabPersistenceService.loadAllTabs(); const filtered: PersistedTabData[] = []; for (const tabData of allTabs) { if (typeof tabData.lastActiveAt === "number" && shouldArchiveTab(tabData.lastActiveAt)) { - // Remove archived tab from storage - await tabPersistenceManager.removeTab(tabData.uniqueId); + tabPersistenceService.removeTab(tabData.uniqueId); continue; } filtered.push(tabData); @@ -45,11 +48,6 @@ async function loadAndFilterTabs(): Promise { return filtered; } -/** - * Creates browser windows and tabs from persisted data. - * Groups tabs by windowGroupId to recreate window layout. - * Also restores tab groups. - */ async function createTabsFromPersistedData(tabDatas: PersistedTabData[]): Promise { // Group tabs by windowGroupId const windowGroups = new Map(); @@ -61,14 +59,19 @@ async function createTabsFromPersistedData(tabDatas: PersistedTabData[]): Promis windowGroups.get(groupId)!.push(tabData); } - // Load persisted tab groups and window states - const persistedGroups = await tabPersistenceManager.loadAllTabGroups(); - const windowStates = await tabPersistenceManager.loadAllWindowStates(); + // Pre-load all required profiles before creating tabs + const profileIds = new Set(tabDatas.map((t) => t.profileId)); + for (const profileId of profileIds) { + await loadedProfilesController.load(profileId); + } + + // Load persisted layout nodes and window states + const persistedNodes = tabPersistenceService.loadAllLayoutNodes(); + const windowStates = tabPersistenceService.loadAllWindowStates(); const uniqueIdToTabId = new Map(); // Create a window for each window group for (const [windowGroupId, tabs] of windowGroups) { - // Read window state from the dedicated window state store const windowState = windowStates.get(windowGroupId); const windowType: BrowserWindowType = windowState?.isPopup ? "popup" : "normal"; @@ -81,37 +84,41 @@ async function createTabsFromPersistedData(tabDatas: PersistedTabData[]): Promis } const window = await browserWindowsController.create(windowType, windowOptions); - for (const tabData of tabs) { - const tab = await tabsController.createTab(window.id, tabData.profileId, tabData.spaceId, undefined, { - asleep: true, - createdAt: tabData.createdAt, - lastActiveAt: tabData.lastActiveAt, - position: tabData.position, - navHistory: tabData.navHistory, - navHistoryIndex: tabData.navHistoryIndex, - uniqueId: tabData.uniqueId, - title: tabData.title, - faviconURL: tabData.faviconURL || undefined - }); - - uniqueIdToTabId.set(tabData.uniqueId, tab.id); + tabService.beginBatch(); + try { + for (const tabData of tabs) { + // Skip tabs whose profile couldn't be loaded (e.g. deleted profile) + if (!loadedProfilesController.get(tabData.profileId)) { + tabPersistenceService.removeTab(tabData.uniqueId); + continue; + } + + const tab = tabService.createTabInternal(window.id, tabData.profileId, tabData.spaceId, undefined, { + asleep: true, + createdAt: tabData.createdAt, + lastActiveAt: tabData.lastActiveAt, + position: tabData.position, + navHistory: tabData.navHistory, + navHistoryIndex: tabData.navHistoryIndex, + uniqueId: tabData.uniqueId, + title: tabData.title, + faviconURL: tabData.faviconURL || undefined + }); + + uniqueIdToTabId.set(tabData.uniqueId, tab.id); + } + } finally { + tabService.endBatch(); } } - await restoreTabGroups(persistedGroups, uniqueIdToTabId); + restoreLayoutNodes(persistedNodes, uniqueIdToTabId); } -/** - * Restores tab groups from persisted data using the uniqueId -> tabId mapping. - */ -async function restoreTabGroups( - persistedGroups: PersistedTabGroupData[], - uniqueIdToTabId: Map -): Promise { - for (const groupData of persistedGroups) { - // Resolve uniqueIds to runtime tab IDs +function restoreLayoutNodes(persistedNodes: PersistedTabLayoutNodeData[], uniqueIdToTabId: Map): void { + for (const nodeData of persistedNodes) { const tabIds: number[] = []; - for (const uniqueId of groupData.tabUniqueIds) { + for (const uniqueId of nodeData.tabUniqueIds) { const tabId = uniqueIdToTabId.get(uniqueId); if (tabId !== undefined) { tabIds.push(tabId); @@ -119,27 +126,14 @@ async function restoreTabGroups( } if (tabIds.length < 2) { - // Tab groups need at least 2 tabs - try { - await tabPersistenceManager.removeTabGroup(groupData.groupId); - } catch (error) { - console.error("Failed to remove stale tab group:", error); - } + tabPersistenceService.removeLayoutNode(nodeData.id); continue; } - try { - const group = tabsController.createTabGroup(groupData.mode, tabIds as [number, ...number[]], groupData.groupId); + // Get the window from the first tab + const firstTab = tabService.getTabById(tabIds[0]); + if (!firstTab) continue; - // Restore glance front tab - if (groupData.mode === "glance" && groupData.glanceFrontTabUniqueId) { - const frontTabId = uniqueIdToTabId.get(groupData.glanceFrontTabUniqueId); - if (frontTabId !== undefined && group instanceof GlanceTabGroup) { - group.setFrontTab(frontTabId); - } - } - } catch (error) { - console.error("Failed to restore tab group:", error); - } + tabService.createLayoutNode(firstTab.getWindow().id, nodeData.mode, tabIds); } } diff --git a/src/main/saving/tabs/serialization.ts b/src/main/saving/tabs/serialization.ts deleted file mode 100644 index 838ff8eac..000000000 --- a/src/main/saving/tabs/serialization.ts +++ /dev/null @@ -1,181 +0,0 @@ -import { Tab, SLEEP_MODE_URL } from "@/controllers/tabs-controller/tab"; -import { PreSleepState } from "@/controllers/tabs-controller/tab-lifecycle"; -import { TabGroup } from "@/controllers/tabs-controller/tab-groups"; -import { - PersistedTabData, - PersistedTabGroupData, - TabData, - TabGroupData, - TAB_SCHEMA_VERSION, - NavigationEntry -} from "~/types/tabs"; - -/** - * Removes sleep mode entries from a navigation history array. - * These entries are synthetic (added by older versions at restore time) - * and must never be persisted — accumulating them across sessions produces - * stale pageState blobs that can crash Chromium's image decoders. - * - * Note: With the current implementation, sleep mode entries should no longer - * be created (the view is destroyed instead of navigating to about:blank). - * This function is kept for backward compatibility with older persisted data. - * - * Adjusts navHistoryIndex to account for removed entries before the - * active index. If the active entry itself is a sleep URL, falls back - * to the last non-sleep entry. - */ -function stripSleepEntries( - navHistory: NavigationEntry[], - navHistoryIndex: number -): { navHistory: NavigationEntry[]; navHistoryIndex: number } { - const filtered: NavigationEntry[] = []; - let adjustedIndex = navHistoryIndex; - let removedBeforeIndex = 0; - - for (let i = 0; i < navHistory.length; i++) { - if (navHistory[i].url === SLEEP_MODE_URL) { - if (i < navHistoryIndex) { - removedBeforeIndex++; - } else if (i === navHistoryIndex) { - // Active entry is a sleep URL — will need to pick a fallback - removedBeforeIndex++; // treat as "before" for index adjustment - } - continue; - } - filtered.push(navHistory[i]); - } - - adjustedIndex = navHistoryIndex - removedBeforeIndex; - - // Clamp to valid range - if (filtered.length === 0) { - return { navHistory: [], navHistoryIndex: 0 }; - } - adjustedIndex = Math.max(0, Math.min(adjustedIndex, filtered.length - 1)); - - return { navHistory: filtered, navHistoryIndex: adjustedIndex }; -} - -/** - * Serializes a Tab instance into PersistedTabData for disk storage. - * Only includes fields that are meaningful across restarts. - * - * @param tab - The tab to serialize - * @param windowGroupId - The window group ID string (e.g. "w-1") - * @param preSleepState - Optional pre-sleep state from TabLifecycleManager. - * When a tab is asleep, the webContents is destroyed. - * The pre-sleep state contains the "real" URL and nav history. - * - * To add a new persisted field: - * 1. Add the field to PersistedTabData in shared/types/tabs.ts - * 2. Add the serialization here - */ -export function serializeTab(tab: Tab, windowGroupId: string, preSleepState?: PreSleepState | null): PersistedTabData { - // For sleeping tabs, use the pre-sleep URL/navHistory - // rather than the webContents data (which would be about:blank?sleep=true) - const url = preSleepState?.url ?? tab.url; - const rawNavHistory = preSleepState?.navHistory ?? tab.navHistory; - const rawNavHistoryIndex = preSleepState?.navHistoryIndex ?? tab.navHistoryIndex; - - // Strip sleep mode entries from nav history — they are synthetic and must - // never be persisted. Accumulating them across sessions causes stale - // pageState data that can crash Chromium's image decoders. - const { navHistory, navHistoryIndex } = stripSleepEntries(rawNavHistory, rawNavHistoryIndex); - - return { - schemaVersion: TAB_SCHEMA_VERSION, - uniqueId: tab.uniqueId, - createdAt: tab.createdAt, - lastActiveAt: tab.lastActiveAt, - position: tab.position, - - profileId: tab.profileId, - spaceId: tab.spaceId, - windowGroupId, - - title: tab.title, - url, - faviconURL: tab.faviconURL, - muted: tab.muted, - - navHistory, - navHistoryIndex - }; -} - -/** - * Serializes a Tab instance into TabData for the renderer process. - * Includes persisted fields (minus navHistory) plus runtime-only fields. - * - * navHistory/navHistoryIndex are deliberately excluded — the renderer never - * reads them and they can be large. Skipping them avoids expensive - * serialization/IPC on every tab state update during page loads. - * - * @param tab - The tab to serialize - * @param preSleepState - Optional pre-sleep state from TabLifecycleManager - */ -export function serializeTabForRenderer(tab: Tab, preSleepState?: PreSleepState | null): TabData { - const windowId = tab.getWindow().id; - - // Use pre-sleep URL for sleeping tabs (webContents would show about:blank) - const url = preSleepState?.url ?? tab.url; - - return { - // Persisted fields (excluding navHistory/navHistoryIndex) - schemaVersion: TAB_SCHEMA_VERSION, - uniqueId: tab.uniqueId, - createdAt: tab.createdAt, - lastActiveAt: tab.lastActiveAt, - position: tab.position, - profileId: tab.profileId, - spaceId: tab.spaceId, - windowGroupId: `w-${windowId}`, - title: tab.title, - url, - faviconURL: tab.faviconURL, - muted: tab.muted, - - // Runtime-only fields - id: tab.id, - windowId, - isLoading: tab.isLoading, - audible: tab.audible, - fullScreen: tab.fullScreen, - isPictureInPicture: tab.isPictureInPicture, - asleep: tab.asleep, - ephemeral: tab.ephemeral || undefined - }; -} - -/** - * Serializes a TabGroup into PersistedTabGroupData for disk storage. - * References tabs by uniqueId rather than runtime webContents.id. - */ -export function serializeTabGroup(tabGroup: TabGroup): PersistedTabGroupData { - return { - groupId: tabGroup.groupId, - mode: tabGroup.mode, - profileId: tabGroup.profileId, - spaceId: tabGroup.spaceId, - tabUniqueIds: tabGroup.tabs.map((tab) => tab.uniqueId), - glanceFrontTabUniqueId: - tabGroup.mode === "glance" ? tabGroup.tabs.find((t) => t.id === tabGroup.frontTabId)?.uniqueId : undefined, - position: tabGroup.position - }; -} - -/** - * Serializes a TabGroup into TabGroupData for the renderer process. - * Uses runtime tab IDs for renderer consumption. - */ -export function serializeTabGroupForRenderer(tabGroup: TabGroup): TabGroupData { - return { - id: tabGroup.groupId, - mode: tabGroup.mode, - profileId: tabGroup.profileId, - spaceId: tabGroup.spaceId, - tabIds: tabGroup.tabs.map((tab) => tab.id), - glanceFrontTabId: tabGroup.mode === "glance" ? tabGroup.frontTabId : undefined, - position: tabGroup.position - }; -} diff --git a/src/main/services/tab-service/AGENTS.md b/src/main/services/tab-service/AGENTS.md new file mode 100644 index 000000000..07623fb95 --- /dev/null +++ b/src/main/services/tab-service/AGENTS.md @@ -0,0 +1,145 @@ +# Tab Service — Agent Guide + +> **Maintenance rule:** When you modify any code in this directory, update this file to reflect +> the change (new classes, renamed methods, changed invariants, new patterns, etc.). Keep this +> document accurate and current — future agents rely on it. + +## Architecture Overview + +Tab Service v2 is the central system for managing tabs, pinned tabs, layouts, and tab-related IPC in Flow Browser. It replaced the old `tabs-controller` with an OOP, event-driven design. + +### Singleton Initialization + +``` +tabService → TabService instance (central orchestrator) +tabPersistenceService → TabPersistenceService (save/restore to SQLite) +tabIPC → TabIPC (renderer communication) +initializeTabService() → called at app startup after DB is ready +``` + +All singletons are created in `index.ts`. + +### Core Classes + +| Class | File | Purpose | +| ----------------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `TabService` | `tab-service.ts` | Central orchestrator. Manages all tabs, pinned tabs, layouts. Emits events for IPC, persistence, and sync. | +| `Tab` | `core/tab.ts` | Represents a single browser tab. Owns its WebContentsView, layer, lifecycle (sleep/wake), favicon, navigation history, and fullscreen state. Emits `"updated"` on property changes. | +| `TabLayoutNode` | `core/tab-layout-node.ts` | Display grouping of 1+ tabs. Modes: `"single"`, `"glance"` (stacked preview), `"split"` (side-by-side). Can exist in multiple layouts (STAW / pinned tabs). Has an `activeLayout` — real content shows there, placeholders elsewhere. | +| `TabLayout` | `layout/tab-layout.ts` | Per window-space. Tracks active node, focused tab, activation history. Controls visibility of its nodes. | +| `TabPositioner` | `layout/tab-positioner.ts` | Manages tab ordering via floating-point positions. Supports insert-top, insert-bottom, insert-after, and normalization. | +| `PinnedTab` | `core/pinned-tab.ts` | Persistent URL shortcut tied to a profile. Has per-space associations (spaceId → tabId). Stores a direct reference to its shared `layoutNode`. | +| `TabIPC` | `ipc/tab-ipc.ts` | Handles all IPC with renderer. Debounced (32ms) structural and content change notifications. Per-tab serialization cache with dirty tracking. Batch suppression for session restore. | +| `TabPersistenceService` | `persistence/tab-persistence-service.ts` | Autosaves tab state to SQLite on a timer. Restores tabs on startup. | +| `PinnedTabPersistence` | `persistence/pinned-tab-persistence.ts` | Save/load pinned tabs to/from DB. | +| `RecentlyClosedManager` | `core/recently-closed-manager.ts` | Tracks recently closed tabs for "Reopen Closed Tab" functionality. | + +### Supporting Modules + +| Module | File | Purpose | +| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------- | +| `tab-sync.ts` | Tab sync across windows. Screenshot placeholders, `moveTabToWindowIfNeeded`, `ensureNodeInLayout`. Pinned tabs always sync; normal tabs sync when setting enabled. | +| `tab-lifecycle-timer.ts` | 10s interval that auto-sleeps/archives inactive tabs. | +| `tab-context-menus.ts` | Sidebar tab right-click menu (Copy URL, Mute, Duplicate, Move To, Close, Pin/Unpin, Reopen). | +| `web-context-menu.ts` | Web page right-click context menu. | +| `save-image-as.ts` | "Save Image As" dialog helper. | + +## Key Patterns & Invariants + +### Event Flow + +``` +Tab property changes → Tab emits "updated" → wireTabEvents handler → + 1. tab.notifyExtensionsOfChanges() (emits "tab-updated" on webContents → extensions library) + 2. tabService.emitContentChange() (→ TabIPC debounced → renderer) +``` + +### Extension Notifications + +- `Tab.notifyExtensionsOfChanges()` emits `"tab-updated"` on `webContents`. This triggers `electron-chrome-extensions` to re-read `assignTabDetails` (title, url, favicon, discarded, index). +- `TabService.notifyIndexChanges(windowId, profileId)` calls `notifyExtensionsOfChanges()` on ALL tabs in the window+profile. Called after any structural change that shifts indices (create, destroy, move, reorder). +- `getTabsInWindowProfile(windowId, profileId)` returns tabs sorted by space order then `tab.position`. Used by `getTabIndexInWindowProfile(tab)` for the extension `tabDetails.index`. + +### Layout & Multi-Layout Membership + +- A `TabLayoutNode` can belong to multiple `TabLayout`s (via `_memberLayouts`). +- `activeLayout` determines where real content shows; other layouts show placeholders. +- Pinned tab nodes are propagated to ALL layouts of the same profile via `propagatePinnedTabNode`. +- Cross-window moves use `ensureNodeInLayout` (registers in target layout, sets activeLayout) — NOT destruction+recreation. + +### Tab Positioning + +- `tab.position` is a floating-point value. Lower = higher in sidebar. +- New tabs get `smallestPosition - 1` (insert at top) by default. +- `normalizePositions(windowId, spaceId)` rewrites positions to 0, 1, 2, ... after structural changes. +- Duplicate tabs use `sourceTab.position + 0.5` then `normalizePositions`. + +### IPC & Serialization Cache + +- `TabIPC` debounces at 32ms. Two queues: structural (full payload) and content (tab-specific dirty fields). +- Per-tab serialization cache (`tabCache`): only re-serializes dirty tabs. +- `beginBatch()` / `endBatch()` suppresses emissions during session restore. +- On structural changes, ALL tabs in affected windows have their cache evicted to guarantee fresh index/position data. + +### Tab Sync (STAW) + +- When enabled, all windows share the same tab set. Focusing a window moves the active tab's view there via `moveTabToWindowIfNeeded`. +- `sendPlaceholderForTab` captures a screenshot and sends it to the old window. +- Pinned tabs ALWAYS sync (regardless of the sync setting). +- Cross-window moves for pinned tabs just call `setWindow()` — no layout migration needed (node already propagated). + +### PinnedTab Lifecycle + +1. Created via `tabService.createPinnedTab(profileId, url, favicon)`. +2. First click in a space: `clickPinnedTab` → `createTab` with `owner: { kind: "pinned-tab" }` → associates tab with space. +3. Subsequent clicks: activates existing associated tab. +4. Cross-window click: captures placeholder in old window, calls `tab.setWindow()` (no layout migration). +5. `pinnedTab.layoutNode` stores direct reference to the shared node. +6. Pinning an existing live tab must immediately set `pinnedTab.layoutNode` and propagate that node to all same-profile layouts. + +## Common Pitfalls + +1. **`tab.spaceId` vs `window.currentSpaceId`** — For pinned tabs, `tab.spaceId` is the _creation_ space, not necessarily the space the tab is active in. Always use `window.currentSpaceId` when looking up the current layout for pinned tab operations. + +2. **Serialization cache staleness** — If you add a new tab property that appears in the IPC payload, make sure it emits `content-change` when it changes (not just `structural-change`). The cache is only evicted for structural changes at payload build time. + +3. **`normalizePositions` after reorders** — Any operation that creates fractional positions (duplicate, insert-after) MUST call `normalizePositions` afterward. Otherwise `getTabsInWindowProfile` may produce unstable ordering. + +4. **Post-await guards** — Any method that `await`s (e.g., `sendPlaceholderForTab`) must check `tab.isDestroyed` / `window.destroyed` after the await before proceeding. + +5. **Extension index correctness** — `getTabsInWindowProfile` must produce deterministic results. Sort by `tab.position` within each space (not by node.position, which can collide across spaces). + +6. **Node destruction cascades** — Destroying a `TabLayoutNode` removes it from ALL member layouts. For pinned tabs, never destroy the shared node on cross-window moves — just change `activeLayout`. + +7. **Lifecycle setting values** — `tab-lifecycle-timer.ts` must use `ArchiveTabValueMap` / `SleepTabValueMap` from `basic-settings`, not parse setting IDs as durations. Those maps are the canonical behavior contract for archive/sleep thresholds. + +8. **Hidden layout visibility** — `updateTabVisibility` must not reveal tabs for hidden layouts. Hidden layouts may still update active/focused metadata, but visible layers are only changed after their space becomes current. + +9. **Renderer-initiated new tabs** — For `window.open()` and web context-menu actions, derive the target space from the tab's current window at action time. Pinned/STAW tabs may be rendered in a different window/space than `tab.spaceId`. + +## File Overview + +``` +tab-service/ + index.ts Entry point, singleton creation, exports + tab-service.ts TabService class (~1800 lines) + tab-sync.ts Cross-window sync, placeholders + tab-lifecycle-timer.ts Auto-sleep/archive timer + core/ + tab.ts Tab class (~850 lines) + tab-layout-node.ts TabLayoutNode class (~320 lines) + pinned-tab.ts PinnedTab class (~140 lines) + recently-closed-manager.ts Recently closed tab tracking + tab-context-menus.ts Sidebar context menu + web-context-menu.ts Page context menu + save-image-as.ts Save image dialog + layout/ + tab-layout.ts TabLayout class (~380 lines) + tab-positioner.ts Position math (~70 lines) + ipc/ + tab-ipc.ts IPC handlers + debounced emission (~570 lines) + preload-api.ts Preload bridge API types + persistence/ + tab-persistence-service.ts Autosave/restore tabs + pinned-tab-persistence.ts Pinned tab DB operations +``` diff --git a/src/main/services/tab-service/core/pinned-tab.ts b/src/main/services/tab-service/core/pinned-tab.ts new file mode 100644 index 000000000..d3d90fa68 --- /dev/null +++ b/src/main/services/tab-service/core/pinned-tab.ts @@ -0,0 +1,144 @@ +import { TypedEventEmitter } from "@/modules/typed-event-emitter"; +import { generateID } from "@/modules/utils"; +import { PersistedPinnedTabData } from "~/types/tab-service"; +import type { TabLayoutNode } from "./tab-layout-node"; + +/** + * PinnedTab — a persistent URL shortcut tied to a profile. + * + * Core component of the TabService. Each pinned tab can have one + * associated live tab per space, allowing each space to have its own + * instance of the pinned URL. + * + * Future: Bookmarks will follow the same pattern — a persisted entity + * that opens as itself (not as a new tab). + */ + +type PinnedTabEvents = { + "association-changed": []; + updated: []; + destroyed: []; +}; + +export class PinnedTab extends TypedEventEmitter { + public readonly uniqueId: string; + public readonly profileId: string; + public defaultUrl: string; + public faviconUrl: string | null; + public position: number; + + /** Runtime: spaceId -> associated tab ID */ + private _associations: Map = new Map(); + + /** Runtime: the shared layout node for this pinned tab (exists in all profile layouts). */ + public layoutNode: TabLayoutNode | null = null; + + constructor(data: PersistedPinnedTabData) { + super(); + + this.uniqueId = data.uniqueId; + this.profileId = data.profileId; + this.defaultUrl = data.defaultUrl; + this.faviconUrl = data.faviconUrl; + this.position = data.position; + } + + // --- Factory --- + + public static create(profileId: string, defaultUrl: string, faviconUrl: string | null, position: number): PinnedTab { + return new PinnedTab({ + uniqueId: generateID(), + profileId, + defaultUrl, + faviconUrl, + position + }); + } + + // --- Associations --- + + public get associations(): ReadonlyMap { + return this._associations; + } + + public getAssociatedTabId(spaceId: string): number | null { + return this._associations.get(spaceId) ?? null; + } + + public getAssociatedTabIds(): Record { + const result: Record = {}; + for (const [spaceId, tabId] of this._associations) { + result[spaceId] = tabId; + } + return result; + } + + public associate(spaceId: string, tabId: number): void { + this._associations.set(spaceId, tabId); + this.emit("association-changed"); + } + + public dissociate(spaceId: string): void { + if (this._associations.has(spaceId)) { + this._associations.delete(spaceId); + this.emit("association-changed"); + } + } + + public dissociateByTabId(tabId: number): boolean { + let changed = false; + + for (const [spaceId, associatedTabId] of this._associations) { + if (associatedTabId === tabId) { + this._associations.delete(spaceId); + changed = true; + } + } + + if (changed) { + this.emit("association-changed"); + } + return changed; + } + + public hasAssociation(tabId: number): boolean { + for (const associatedTabId of this._associations.values()) { + if (associatedTabId === tabId) return true; + } + return false; + } + + // --- Updates --- + + public updateFavicon(faviconUrl: string | null): void { + if (this.faviconUrl === faviconUrl) return; + this.faviconUrl = faviconUrl; + this.emit("updated"); + } + + public updatePosition(position: number): void { + if (this.position === position) return; + this.position = position; + this.emit("updated"); + } + + // --- Serialization --- + + public toPersistedData(): PersistedPinnedTabData { + return { + uniqueId: this.uniqueId, + profileId: this.profileId, + defaultUrl: this.defaultUrl, + faviconUrl: this.faviconUrl, + position: this.position + }; + } + + // --- Lifecycle --- + + public destroy(): void { + this._associations.clear(); + this.emit("destroyed"); + this.destroyEmitter(); + } +} diff --git a/src/main/services/tab-service/core/recently-closed-manager.ts b/src/main/services/tab-service/core/recently-closed-manager.ts new file mode 100644 index 000000000..dcc31786c --- /dev/null +++ b/src/main/services/tab-service/core/recently-closed-manager.ts @@ -0,0 +1,51 @@ +import { TypedEventEmitter } from "@/modules/typed-event-emitter"; +import { RecentlyClosedTabData, PersistedTabData, PersistedTabLayoutNodeData } from "~/types/tab-service"; + +const MAX_RECENTLY_CLOSED = 10; + +type RecentlyClosedEvents = { + changed: []; +}; + +/** + * Runtime-only store for recently closed tabs. + * Closed tabs should never survive an app restart. + */ +export class RecentlyClosedManager extends TypedEventEmitter { + private entries: RecentlyClosedTabData[] = []; + + add(tabData: PersistedTabData, layoutNodeData?: PersistedTabLayoutNodeData): void { + const closedAt = Date.now(); + this.entries = this.entries.filter((entry) => entry.tabData.uniqueId !== tabData.uniqueId); + this.entries.unshift({ closedAt, tabData, layoutNodeData }); + this.entries.length = Math.min(this.entries.length, MAX_RECENTLY_CLOSED); + this.emit("changed"); + } + + getAll(): RecentlyClosedTabData[] { + return [...this.entries]; + } + + hasEntries(): boolean { + return this.entries.length > 0; + } + + peekMostRecent(): RecentlyClosedTabData | null { + return this.entries[0] ?? null; + } + + restore(uniqueId: string): { tabData: PersistedTabData; layoutNodeData?: PersistedTabLayoutNodeData } | null { + const index = this.entries.findIndex((entry) => entry.tabData.uniqueId === uniqueId); + if (index === -1) return null; + + const [row] = this.entries.splice(index, 1); + this.emit("changed"); + return { tabData: row.tabData, layoutNodeData: row.layoutNodeData }; + } + + clear(): void { + if (this.entries.length === 0) return; + this.entries = []; + this.emit("changed"); + } +} diff --git a/src/main/controllers/tabs-controller/save-image-as.ts b/src/main/services/tab-service/core/save-image-as.ts similarity index 99% rename from src/main/controllers/tabs-controller/save-image-as.ts rename to src/main/services/tab-service/core/save-image-as.ts index be132c0e1..52f5049a3 100644 --- a/src/main/controllers/tabs-controller/save-image-as.ts +++ b/src/main/services/tab-service/core/save-image-as.ts @@ -49,7 +49,6 @@ async function getImageResourceFromSession( credentials: "include", referrer: getFetchReferrer(parameters.referrerPolicy), referrerPolicy: getFetchReferrerPolicy(parameters.referrerPolicy), - // abort after 10 seconds signal: AbortSignal.timeout(10000) }); diff --git a/src/main/services/tab-service/core/tab-context-menus.ts b/src/main/services/tab-service/core/tab-context-menus.ts new file mode 100644 index 000000000..99737aaba --- /dev/null +++ b/src/main/services/tab-service/core/tab-context-menus.ts @@ -0,0 +1,227 @@ +import { clipboard, Menu, MenuItem } from "electron"; +import { BrowserWindow } from "@/controllers/windows-controller/types"; +import { spacesController } from "@/controllers/spaces-controller"; +import type { TabService } from "../tab-service"; +import type { Tab } from "./tab"; + +/** + * Builds a "Move To" submenu listing all spaces for the tab's profile. + */ +async function buildMoveToSubmenu(tabService: TabService, tab: Tab): Promise { + const spaces = await spacesController.getAllFromProfile(tab.profileId); + const currentSpaceId = tab.spaceId; + + const submenu = new Menu(); + for (const space of spaces) { + submenu.append( + new MenuItem({ + label: space.name, + enabled: space.id !== currentSpaceId, + click: () => { + tabService.moveTabToSpace(tab.id, space.id); + } + }) + ); + } + + return new MenuItem({ label: "Move To", submenu }); +} + +/** + * Shows the context menu for a tab in the sidebar (works for both normal and pinned-tab-owned tabs). + */ +export async function showTabContextMenu(tabService: TabService, tabId: number, window: BrowserWindow): Promise { + const tab = tabService.tabs.get(tabId); + if (!tab) return; + + const isPinned = tab.owner.kind === "pinned"; + const hasURL = !!tab.url; + + const contextMenu = new Menu(); + + // --- Copy URL --- + contextMenu.append( + new MenuItem({ + label: "Copy URL", + enabled: hasURL, + click: () => { + if (tab.url) clipboard.writeText(tab.url); + } + }) + ); + + // --- Reset URL to Default (pinned tabs only) --- + if (tab.owner.kind === "pinned") { + const pinnedTab = tabService.pinnedTabs.get(tab.owner.pinnedTabId); + const isOnDifferentUrl = pinnedTab && tab.url !== pinnedTab.defaultUrl; + + contextMenu.append( + new MenuItem({ + label: "Reset URL to Default", + enabled: !!isOnDifferentUrl, + click: () => { + if (pinnedTab && !tab.isDestroyed) { + tab.loadURL(pinnedTab.defaultUrl); + } + } + }) + ); + } + + contextMenu.append(new MenuItem({ type: "separator" })); + + // --- Mute --- + const isMuted = tab.muted; + contextMenu.append( + new MenuItem({ + label: isMuted ? "Unmute Tab" : "Mute Tab", + enabled: !!tab.webContents && !tab.webContents.isDestroyed(), + click: () => { + tabService.setTabMuted(tab.id, !isMuted); + } + }) + ); + + // --- Duplicate --- + contextMenu.append( + new MenuItem({ + label: "Duplicate Tab", + enabled: hasURL, + click: () => { + if (tab.url) { + const targetSpaceId = window.currentSpaceId ?? tab.spaceId; + void tabService + .createTab(window.id, tab.profileId, targetSpaceId, undefined, { + url: tab.url, + position: tab.position + 0.5 + }) + .then(() => { + tabService.normalizePositions(window.id, targetSpaceId); + }); + } + } + }) + ); + + // --- Move To (not applicable for pinned tabs — they exist in all spaces) --- + if (!isPinned) { + const moveToItem = await buildMoveToSubmenu(tabService, tab); + contextMenu.append(moveToItem); + } + + contextMenu.append(new MenuItem({ type: "separator" })); + + // --- Close Tab --- + contextMenu.append( + new MenuItem({ + label: "Close Tab", + click: () => { + tab.destroy(); + } + }) + ); + + // --- Close Tabs Below --- + // Use the window's current space (not tab.spaceId which is creation space for pinned tabs) + const spaceForContext = window.currentSpaceId ?? tab.spaceId; + const tabsInSpace = tabService.getTabsInWindowSpace(window.id, spaceForContext); + const tabsBelow = tabsInSpace.filter((t) => t.position > tab.position && t.id !== tab.id); + contextMenu.append( + new MenuItem({ + label: "Close Tabs Below", + enabled: tabsBelow.length > 0, + click: () => { + for (const t of tabsBelow) { + t.destroy(); + } + } + }) + ); + + contextMenu.append(new MenuItem({ type: "separator" })); + + // --- Pin / Unpin --- + contextMenu.append( + new MenuItem({ + label: isPinned ? "Unpin Tab" : "Pin Tab", + enabled: hasURL, + click: () => { + if (tab.owner.kind === "pinned") { + void tabService.unpinToTabList(tab.owner.pinnedTabId, window, tab.position); + } else { + tabService.createPinnedTabFromTab(tabId); + } + } + }) + ); + + // --- Reopen Closed Tab --- + const mostRecent = tabService.recentlyClosed.peekMostRecent(); + const mostRecentTitle = mostRecent?.tabData.title; + const truncatedTitle = + mostRecentTitle && mostRecentTitle.length > 35 + ? mostRecentTitle.slice(0, 35).trim() + "..." + : mostRecentTitle?.trim(); + + contextMenu.append( + new MenuItem({ + label: truncatedTitle ? `Reopen Closed Tab (${truncatedTitle})` : "Reopen Closed Tab", + enabled: tabService.recentlyClosed.hasEntries(), + click: () => { + if (mostRecent) { + tabService.restoreRecentlyClosed(mostRecent.tabData.uniqueId, window).catch((error) => { + console.error("Failed to restore recently closed tab:", error); + }); + } + } + }) + ); + + contextMenu.popup({ window: window.browserWindow }); +} + +/** + * Shows the context menu for a pinned tab in the pin grid. + * Delegates to the unified tab context menu if the tab has an associated tab, + * otherwise shows a minimal menu. + */ +export async function showPinnedTabContextMenu( + tabService: TabService, + pinnedTabId: string, + window: BrowserWindow +): Promise { + const pinnedTab = tabService.pinnedTabs.get(pinnedTabId); + if (!pinnedTab) return; + + // If there's an associated tab for the current space, use the unified menu + const currentSpaceId = window.currentSpaceId; + const associatedTabId = currentSpaceId ? pinnedTab.getAssociatedTabId(currentSpaceId) : null; + if (associatedTabId !== null) { + return showTabContextMenu(tabService, associatedTabId, window); + } + + // Minimal menu for pinned tabs with no associated tab (not yet activated) + const contextMenu = new Menu(); + + contextMenu.append( + new MenuItem({ + label: "Copy URL", + click: () => { + clipboard.writeText(pinnedTab.defaultUrl); + } + }) + ); + + contextMenu.append(new MenuItem({ type: "separator" })); + + contextMenu.append( + new MenuItem({ + label: "Unpin Tab", + click: () => { + void tabService.unpinToTabList(pinnedTabId, window); + } + }) + ); + + contextMenu.popup({ window: window.browserWindow }); +} diff --git a/src/main/services/tab-service/core/tab-layout-node.ts b/src/main/services/tab-service/core/tab-layout-node.ts new file mode 100644 index 000000000..faa02c98d --- /dev/null +++ b/src/main/services/tab-service/core/tab-layout-node.ts @@ -0,0 +1,324 @@ +import { TypedEventEmitter } from "@/modules/typed-event-emitter"; +import { Tab } from "./tab"; +import { TabLayoutNodeMode } from "~/types/tab-service"; +import type { LayerType } from "~/layers"; +import type { TabLayout } from "../layout/tab-layout"; + +/** + * TabLayoutNode — represents tabs displayed together in a window. + * + * In the old system this was "TabGroup" with modes (glance, split). + * In the new system we explicitly define this as a "layout node" to + * avoid confusion with folder-like tab groups. + * + * A single tab is represented as a layout node with mode "single". + * Multi-tab modes include "glance" (stacked preview) and "split" (side-by-side). + */ + +type TabLayoutNodeEvents = { + "tab-added": [Tab]; + "tab-removed": [Tab]; + "front-tab-changed": [Tab | null]; + "space-changed": [oldSpaceId: string]; + changed: []; + destroyed: []; +}; + +export class TabLayoutNode extends TypedEventEmitter { + public readonly id: string; + public mode: TabLayoutNodeMode; + public isDestroyed: boolean = false; + + public windowId: number; + public profileId: string; + public spaceId: string; + + private _tabs: Tab[] = []; + private _tabIdSet: Set = new Set(); + private _frontTab: Tab | null = null; + private _destroyListeners: Map void> = new Map(); + + /** + * All layouts this node is registered in. + * A node may belong to multiple layouts when STAW (sync tabs across windows) + * is enabled, or for pinned tabs which exist in all profile layouts. + */ + private _memberLayouts: Set = new Set(); + + /** + * The layout where this node shows real content. + * Other member layouts show a placeholder thumbnail. + * Null if the node is only in one layout (default case). + */ + private _activeLayout: TabLayout | null = null; + + constructor(id: string, mode: TabLayoutNodeMode, initialTab: Tab, windowId: number) { + super(); + + this.id = id; + this.mode = mode; + this.windowId = windowId; + this.profileId = initialTab.profileId; + this.spaceId = initialTab.spaceId; + + this.addTab(initialTab); + } + + // --- Accessors --- + + public get tabs(): readonly Tab[] { + return this._tabs; + } + + public get tabIds(): number[] { + return this._tabs.map((t) => t.id); + } + + public get frontTab(): Tab | null { + return this._frontTab; + } + + public get position(): number { + if (this._tabs.length === 0) return 0; + return Math.min(...this._tabs.map((t) => t.position)); + } + + public get tabCount(): number { + return this._tabs.length; + } + + // --- Multi-Layout Membership --- + + public get memberLayouts(): ReadonlySet { + return this._memberLayouts; + } + + public get activeLayout(): TabLayout | null { + return this._activeLayout; + } + + /** + * Whether this node shows real content in the given layout, + * or a placeholder thumbnail. + */ + public isActiveInLayout(layout: TabLayout): boolean { + // If no multi-layout, always active in its sole layout + if (this._activeLayout === null) return true; + return this._activeLayout === layout; + } + + public addMemberLayout(layout: TabLayout): void { + this._memberLayouts.add(layout); + // If first layout, it's active by default + if (this._activeLayout === null && this._memberLayouts.size === 1) { + this._activeLayout = layout; + } + } + + public removeMemberLayout(layout: TabLayout): void { + this._memberLayouts.delete(layout); + if (this._activeLayout === layout) { + // Fall back to first remaining layout or null + this._activeLayout = this._memberLayouts.size > 0 ? this._memberLayouts.values().next().value! : null; + } + } + + /** + * Set the active layout (shows real content). Other layouts show placeholder. + * Emits "active-layout-changed" so the sync system can update placeholders. + */ + public setActiveLayout(layout: TabLayout): void { + if (!this._memberLayouts.has(layout)) return; + if (this._activeLayout === layout) return; + const previous = this._activeLayout; + this._activeLayout = layout; + this.emit("changed"); + // Update windowId to match the active layout + this.windowId = layout.windowId; + void previous; // previous is available for placeholder capture if needed + } + + // --- Tab Management --- + + public hasTab(tabId: number): boolean { + return this._tabIdSet.has(tabId); + } + + public getTab(tabId: number): Tab | undefined { + return this._tabs.find((t) => t.id === tabId); + } + + public addTab(tab: Tab): boolean { + this.checkNotDestroyed(); + + if (this._tabIdSet.has(tab.id)) return false; + + this._tabs.push(tab); + this._tabIdSet.add(tab.id); + + // Set front tab for single-tab nodes + if (this._tabs.length === 1) { + this._frontTab = tab; + } + + // Sync tab to this node's space/window + if (tab.spaceId !== this.spaceId) { + tab.setSpace(this.spaceId); + } + + // Listen for tab destruction (guarded + tracked for cleanup) + const onDestroyed = () => { + this._destroyListeners.delete(tab.id); + if (!this.isDestroyed) this.removeTab(tab); + }; + this._destroyListeners.set(tab.id, onDestroyed); + tab.once("destroyed", onDestroyed); + + this.emit("tab-added", tab); + this.emit("changed"); + return true; + } + + public removeTab(tab: Tab): boolean { + this.checkNotDestroyed(); + + const index = this._tabs.findIndex((t) => t.id === tab.id); + if (index === -1) return false; + + // Remove the destroy listener to prevent stale callbacks + const listener = this._destroyListeners.get(tab.id); + if (listener) { + tab.off("destroyed", listener); + this._destroyListeners.delete(tab.id); + } + + this._tabs.splice(index, 1); + this._tabIdSet.delete(tab.id); + + // Update front tab if needed + if (this._frontTab?.id === tab.id) { + this._frontTab = this._tabs[0] ?? null; + this.emit("front-tab-changed", this._frontTab); + } + + this.emit("tab-removed", tab); + this.emit("changed"); + + // Auto-destroy if empty + if (this._tabs.length === 0) { + this.destroy(); + } + + return true; + } + + // --- Front Tab (for glance mode) --- + + public setFrontTab(tab: Tab): void { + this.checkNotDestroyed(); + + if (!this.hasTab(tab.id)) return; + if (this._frontTab?.id === tab.id) return; + + this._frontTab = tab; + this.emit("front-tab-changed", tab); + this.emit("changed"); + } + + // --- Space/Window --- + + public setSpace(spaceId: string): void { + this.checkNotDestroyed(); + if (this.spaceId === spaceId) return; + + const oldSpaceId = this.spaceId; + this.spaceId = spaceId; + for (const tab of this._tabs) { + tab.setSpace(spaceId); + } + this.emit("space-changed", oldSpaceId); + this.emit("changed"); + } + + public setWindowId(windowId: number): void { + this.checkNotDestroyed(); + if (this.windowId === windowId) return; + + this.windowId = windowId; + this.emit("changed"); + } + + // --- Bounds Calculation (secondary) --- + + /** + * Compute bounds for each tab in this node given the main bounds from TabLayout. + * For single-tab nodes, returns the main bounds directly. + * For multi-tab nodes (split/glance), divides the space accordingly. + */ + public computeBounds(mainBounds: Electron.Rectangle): Map { + const result = new Map(); + + if (this._tabs.length <= 1) { + // Single tab: passthrough + if (this._tabs[0]) { + result.set(this._tabs[0], { bounds: mainBounds, layerType: "tab" }); + } + return result; + } + + if (this.mode === "split") { + const count = this._tabs.length; + const tabWidth = Math.floor(mainBounds.width / count); + for (let i = 0; i < count; i++) { + const width = i === count - 1 ? mainBounds.width - i * tabWidth : tabWidth; + result.set(this._tabs[i], { + bounds: { x: mainBounds.x + i * tabWidth, y: mainBounds.y, width, height: mainBounds.height }, + layerType: "tab" + }); + } + return result; + } + + // Glance mode: front tab at 85% centered, back tabs at 95% centered + for (let i = 0; i < this._tabs.length; i++) { + const tab = this._tabs[i]; + const isFront = this._frontTab === tab; + const widthPct = isFront ? 0.85 : 0.95; + const heightPct = isFront ? 1 : 0.975; + + const newWidth = Math.floor(mainBounds.width * widthPct); + const newHeight = Math.floor(mainBounds.height * heightPct); + const xOffset = Math.floor((mainBounds.width - newWidth) / 2); + const yOffset = Math.floor((mainBounds.height - newHeight) / 2); + + result.set(tab, { + bounds: { x: mainBounds.x + xOffset, y: mainBounds.y + yOffset, width: newWidth, height: newHeight }, + layerType: isFront ? "tab" : "tabBack" + }); + } + return result; + } + + // --- Lifecycle --- + + public destroy(): void { + if (this.isDestroyed) return; + this.isDestroyed = true; + + // Clean up all destroy listeners from remaining tabs + for (const [tabId, listener] of this._destroyListeners) { + const tab = this._tabs.find((t) => t.id === tabId); + if (tab) tab.off("destroyed", listener); + } + this._destroyListeners.clear(); + + this.emit("destroyed"); + this.destroyEmitter(); + } + + private checkNotDestroyed(): void { + if (this.isDestroyed) { + throw new Error(`TabLayoutNode ${this.id} is already destroyed`); + } + } +} diff --git a/src/main/services/tab-service/core/tab.ts b/src/main/services/tab-service/core/tab.ts new file mode 100644 index 000000000..fc4fe5c64 --- /dev/null +++ b/src/main/services/tab-service/core/tab.ts @@ -0,0 +1,854 @@ +import { TypedEventEmitter } from "@/modules/typed-event-emitter"; +import { generateID, getCurrentTimestamp } from "@/modules/utils"; +import { NavigationEntry, Session, WebContents, WebContentsView, WebPreferences } from "electron"; +import { Layer } from "@/controllers/windows-controller/layer-manager"; +import { BrowserWindow } from "@/controllers/windows-controller/types"; +import { LoadedProfile } from "@/controllers/loaded-profiles-controller"; +import { createModalTo, focusPriorities, LayerType, zIndexes } from "~/layers"; +import { TabOwnerRef } from "~/types/tab-service"; +import { cacheFavicon } from "@/modules/favicons"; +import { + isHistoryRecordableUrl, + recordBrowsingHistoryVisit, + updateBrowsingHistoryTitleForOpenPage +} from "@/saving/history/browsing-history"; +import { createWebContextMenu } from "./web-context-menu"; + +export const SLEEP_MODE_URL = "about:blank?sleep=true"; + +// Stable counter-based tab IDs +let nextTabId = 1; + +// --- Types --- + +type TabStateProperty = + | "visible" + | "isDestroyed" + | "faviconURL" + | "fullScreen" + | "isPictureInPicture" + | "asleep" + | "lastActiveAt" + | "position"; + +type TabContentProperty = "title" | "url" | "isLoading" | "audible" | "muted" | "navHistory" | "navHistoryIndex"; + +export type TabPublicProperty = TabStateProperty | TabContentProperty; + +export type TabEvents = { + "space-changed": [oldSpaceId: string]; + "window-changed": [oldWindowId: number]; + "fullscreen-changed": [boolean]; + "target-url-changed": [url: string]; + "new-tab-requested": [ + string, + "new-window" | "foreground-tab" | "background-tab" | "default" | "other", + Electron.WebContentsViewConstructorOptions | undefined, + Electron.HandlerDetails | undefined, + { noLoadURL?: boolean } + ]; + focused: []; + updated: [TabPublicProperty[]]; + "content-changed": []; + destroyed: []; +}; + +export interface TabCreationDetails { + profileId: string; + spaceId: string; + session: Session; + loadedProfile: LoadedProfile; +} + +export interface TabCreationOptions { + uniqueId?: string; + window: BrowserWindow; + webContentsViewOptions?: Electron.WebContentsViewConstructorOptions; + createdAt?: number; + lastActiveAt?: number; + url?: string; + asleep?: boolean; + position?: number; + owner?: TabOwnerRef; + title?: string; + faviconURL?: string; + navHistory?: NavigationEntry[]; + navHistoryIndex?: number; + noLoadURL?: boolean; + typedNavigation?: boolean; + /** If false, the tab won't be activated after creation (default: true) */ + makeActive?: boolean; +} + +function createWebContentsView(session: Session, options: Electron.WebContentsViewConstructorOptions): WebContentsView { + const webContents = options.webContents; + const webPreferences: WebPreferences = { + ...(options.webPreferences || {}), + sandbox: true, + webSecurity: true, + session: session, + scrollBounce: true, + safeDialogs: true, + navigateOnDragDrop: true, + transparent: true, + nodeIntegration: false, + nodeIntegrationInSubFrames: true, + contextIsolation: true + }; + + const webContentsView = new WebContentsView({ + webPreferences, + ...(webContents ? { webContents } : {}) + }); + + webContentsView.setVisible(false); + return webContentsView; +} + +// Background colors +const COLOR_TRANSPARENT = "#00000000"; +const COLOR_BACKGROUND = "#FFFFFF"; +const WHITELISTED_PROTOCOLS = ["flow:", "flow-internal:"]; + +/** + * Tab — core entity owning identity, state, WebContentsView, and event emission. + * + * The view and webContents are nullable: sleeping tabs have no view or + * webContents to save resources (~20-50MB RAM per sleeping tab). + */ +export class Tab extends TypedEventEmitter { + // Identity + public readonly id: number; + public readonly profileId: string; + public spaceId: string; + public readonly uniqueId: string; + + // Ownership — links this tab to a pinned tab, bookmark, or nothing + public owner: TabOwnerRef; + + // State + public visible: boolean = false; + public isDestroyed: boolean = false; + public faviconURL: string | null = null; + public fullScreen: boolean = false; + public isPictureInPicture: boolean = false; + public asleep: boolean = false; + public createdAt: number; + public lastActiveAt: number; + public position: number; + + // History dedup + private pendingHistoryTypedUrl: string | null = null; + private lastRecordedHistoryKey: string = ""; + + // Content properties + public title: string = "New Tab"; + public url: string = ""; + public isLoading: boolean = false; + public audible: boolean = false; + public muted: boolean = false; + public navHistory: NavigationEntry[] = []; + public navHistoryIndex: number = 0; + + // Nav history diff cache + private lastNavHistoryLength: number = 0; + private lastNavHistoryIndex: number = 0; + + // Coalescing + private _updatePending: boolean = false; + private _pendingUpdatedProps: Set = new Set(); + + // Fullscreen cleanup + private _disconnectLeaveFullScreen: (() => void) | null = null; + + // View & content objects (nullable when asleep) + public view: WebContentsView | null = null; + public webContents: WebContents | null = null; + public layer: Layer | null = null; + + // Private + private readonly session: Session; + public readonly loadedProfile: LoadedProfile; + private window!: BrowserWindow; + private readonly _webContentsViewOptions: Electron.WebContentsViewConstructorOptions; + + /** Signals that the tab's initial loadURL should be called after wiring. */ + public _needsInitialLoad: boolean = false; + /** Last webContents created by a new-tab-requested event (for window.open). */ + public _lastCreatedWebContents: WebContents | null = null; + + constructor(details: TabCreationDetails, options: TabCreationOptions) { + super(); + + const { profileId, spaceId, session } = details; + + this.profileId = profileId; + this.spaceId = spaceId; + this.session = session; + this.loadedProfile = details.loadedProfile; + + const { + window, + webContentsViewOptions = {}, + createdAt, + lastActiveAt, + asleep = false, + position, + title, + faviconURL, + navHistory = [], + navHistoryIndex, + uniqueId, + owner = { kind: "normal" } + } = options; + + this._webContentsViewOptions = webContentsViewOptions; + this.uniqueId = uniqueId || generateID(); + this.owner = owner; + this.id = nextTabId++; + + // Position + if (position !== undefined) { + this.position = position; + } else { + this.position = -1; // Will be set by TabPositioner + } + + // Timestamps + const now = getCurrentTimestamp(); + this.createdAt = createdAt ?? now; + this.lastActiveAt = lastActiveAt ?? this.createdAt; + + // Restore visual states + if (title) this.title = title; + if (faviconURL) this.faviconURL = faviconURL; + + this.window = window; + + if (asleep) { + this.asleep = true; + // Nav history stored for pre-sleep state + if (navHistory.length > 0) { + this.navHistory = navHistory; + this.navHistoryIndex = navHistoryIndex ?? navHistory.length - 1; + if (navHistory[this.navHistoryIndex]) { + this.url = navHistory[this.navHistoryIndex].url; + } + } + } else { + this.initializeView(); + this._needsInitialLoad = navHistory.length === 0; + + // Restore nav history on next tick + if (navHistory.length > 0) { + setImmediate(() => { + if (this.isDestroyed) return; + this.restoreNavigationHistory(navHistory, navHistoryIndex ?? navHistory.length - 1); + }); + } + } + } + + // --- Getters --- + + public get ephemeral(): boolean { + return this.owner.kind !== "normal"; + } + + public getWindow(): BrowserWindow { + return this.window; + } + + // --- Window Management --- + + public setWindow(window: BrowserWindow): void { + const oldWindowId = this.window?.id; + if (oldWindowId === window.id) return; + + // Remove from old window + if (this.layer) { + this.window?.layerManager?.pop(this.layer); + } + + this.window = window; + + // Add to new window + if (this.view && this.layer) { + window.layerManager?.push(this.layer); + } else if (this.view) { + this.layer = new Layer(window.layerManager, this.view, zIndexes.tab, focusPriorities.tab, createModalTo("tab")); + window.layerManager?.push(this.layer); + } + + // Update the extensions library's internal window mapping for this tab. + // The library has no public moveTab API, so we patch the store directly. + if (this.webContents && !this.webContents.isDestroyed()) { + const store = ( + this.loadedProfile.extensions as unknown as { + ctx: { store: { tabToWindow: WeakMap } }; + } + ).ctx.store; + store.tabToWindow.set(this.webContents, window.browserWindow); + } + + // Re-attach fullscreen listener to new window + if (this.view) { + this.setupWindowFullScreenListener(); + } + + if (oldWindowId !== undefined) { + this.emit("window-changed", oldWindowId); + } + } + + /** + * Change the layer type (z-index) of this tab's layer. + * Used for glance mode: front tab = "tab" (z10), back tab = "tabBack" (z9). + */ + public setLayerType(layerType: LayerType): void { + if (!this.view || !this.layer || !this.window) return; + if (this.layer.zIndex === zIndexes[layerType]) return; + + const lm = this.window.layerManager; + if (!lm) return; + + const wasVisible = this.layer.isVisible(); + lm.pop(this.layer); + this.layer = new Layer(lm, this.view, zIndexes[layerType], focusPriorities[layerType], createModalTo(layerType)); + lm.push(this.layer); + this.layer.setVisible(wasVisible); + } + + public notifyExtensionsOfChanges(): void { + if (!this.webContents || this.webContents.isDestroyed()) return; + + // electron-chrome-extensions listens to this event to update its internal state + this.webContents.emit("tab-updated"); + } + + // --- Space Management --- + + public setSpace(spaceId: string): void { + if (this.spaceId === spaceId) return; + const oldSpaceId = this.spaceId; + this.spaceId = spaceId; + this.emit("space-changed", oldSpaceId); + } + + // --- View Management --- + + public initializeView(): void { + const view = createWebContentsView(this.session, this._webContentsViewOptions); + this.view = view; + this.webContents = view.webContents; + + // Create layer + this.layer = new Layer(this.window.layerManager, view, zIndexes.tab, focusPriorities.tab, createModalTo("tab")); + this.window.layerManager.push(this.layer); + + this.setupWebContentsListeners(); + this.setupWindowFullScreenListener(); + + // Setup web page context menu (right-click on page content) + createWebContextMenu(this, this.window); + + // Register with extensions + const extensions = this.loadedProfile.extensions; + extensions.addTab(this.webContents, this.window?.browserWindow); + } + + public teardownView(): void { + if (!this.view) return; + + // Disconnect window fullscreen listener + if (this._disconnectLeaveFullScreen) { + this._disconnectLeaveFullScreen(); + this._disconnectLeaveFullScreen = null; + } + + // Remove layer from window + if (this.layer) { + this.window?.layerManager?.pop(this.layer); + this.layer = null; + } + + // Null references before closing so getTabByWebContents() won't find + // this tab during the extensions library's "destroyed" callback. + const wc = this.webContents; + this.view = null; + this.webContents = null; + + if (wc && !wc.isDestroyed()) { + wc.close(); + } + } + + // --- Sleep / Wake --- + + public putToSleep(): void { + if (this.asleep) return; + + this.updateTabState(); + + // Capture nav state before tearing down + if (this.webContents && !this.webContents.isDestroyed()) { + const history = this.webContents.navigationHistory; + const count = history.length(); + const entries: NavigationEntry[] = []; + for (let i = 0; i < count; i++) { + const entry = history.getEntryAtIndex(i); + entries.push({ title: entry.title || "", url: entry.url, pageState: entry.pageState }); + } + this.navHistory = entries; + this.navHistoryIndex = history.getActiveIndex(); + } + + this.updateStateProperty("asleep", true); + this.teardownView(); + } + + public wakeUp(): void { + if (!this.asleep) return; + + this.initializeView(); + this.updateStateProperty("asleep", false); + + if (this.navHistory.length > 0) { + this.restoreNavigationHistory(this.navHistory, this.navHistoryIndex); + } + + this.applyUrlBackground(); + } + + // --- Picture in Picture --- + + public async enterPictureInPicture(): Promise { + if (!this.webContents || this.webContents.isDestroyed()) return false; + + const enterPiP = async function () { + const videos = Array.from(document.querySelectorAll("video")).filter( + (video) => !video.paused && !video.ended && video.readyState > 2 + ); + if (videos.length > 0 && document.pictureInPictureElement !== videos[0]) { + try { + const video = videos[0]; + await video.requestPictureInPicture(); + + const onLeavePiP = () => { + setTimeout(() => { + const goBackToTab = !video.paused && !video.ended; + flow.tabService.disablePictureInPicture(goBackToTab); + }, 50); + video.removeEventListener("leavepictureinpicture", onLeavePiP); + }; + + video.addEventListener("leavepictureinpicture", onLeavePiP); + return true; + } catch (e) { + console.error("Failed to enter Picture in Picture mode:", e); + return false; + } + } + return false; + }; + + try { + const result = await this.webContents.executeJavaScript(`(${enterPiP})()`, true); + if (result) { + this.updateStateProperty("isPictureInPicture", true); + } + return result; + } catch (err) { + console.error("PiP enter error:", err); + return false; + } + } + + public async exitPictureInPicture(): Promise { + if (!this.webContents || this.webContents.isDestroyed()) return false; + + const exitPiP = function () { + if (document.pictureInPictureElement) { + document.exitPictureInPicture(); + return true; + } + return false; + }; + + try { + const result = await this.webContents.executeJavaScript(`(${exitPiP})()`, true); + if (result) { + this.updateStateProperty("isPictureInPicture", false); + } + return result; + } catch (err) { + console.error("PiP exit error:", err); + return false; + } + } + + // --- Fullscreen --- + + public setFullScreen(isFullScreen: boolean): void { + const updated = this.updateStateProperty("fullScreen", isFullScreen); + if (!updated) return; + + const window = this.getWindow(); + if (window.destroyed) return; + const electronWindow = window.browserWindow; + + if (isFullScreen) { + if (!electronWindow.fullScreen) { + electronWindow.setFullScreen(true); + } + } else { + if (electronWindow.fullScreen) { + electronWindow.setFullScreen(false); + } + + // Nudge the view bounds by 1px to force Chromium to recognize the + // viewport change, which is needed to properly exit HTML fullscreen. + const view = this.view; + if (view) { + setTimeout(() => { + if (this.view !== view || !this.visible) return; + + const bounds = view.getBounds(); + const nudged = { ...bounds, width: bounds.width - 1 }; + view.setBounds(nudged); + + setTimeout(() => { + if (this.view !== view || !this.visible) return; + const current = view.getBounds(); + if (current.width !== nudged.width || current.height !== nudged.height) return; + if (current.x !== nudged.x || current.y !== nudged.y) return; + view.setBounds(bounds); + }, 50); + }, 800); + } + + if (this.webContents && !this.webContents.isDestroyed()) { + this.webContents.executeJavaScript(`if (document.fullscreenElement) { document.exitFullscreen(); }`, true); + } + } + this.emit("fullscreen-changed", isFullScreen); + } + + /** + * Listens for the OS window exiting fullscreen and syncs tab state accordingly. + * Idempotent: disconnects any previous listener before registering. + */ + private setupWindowFullScreenListener(): void { + if (this._disconnectLeaveFullScreen) { + this._disconnectLeaveFullScreen(); + this._disconnectLeaveFullScreen = null; + } + + const window = this.window; + if (!window || window.destroyed) return; + + const handler = () => { + this.setFullScreen(false); + }; + window.on("leave-full-screen", handler); + + this._disconnectLeaveFullScreen = () => { + if (!window.destroyed) { + window.off("leave-full-screen", handler); + } + }; + } + + // --- State Updates --- + + public updateStateProperty(key: K, value: this[K]): boolean { + if ((this as Record)[key] === value) return false; + (this as Record)[key] = value; + this.scheduleUpdate([key]); + return true; + } + + public updateTabState(): void { + if (!this.webContents || this.webContents.isDestroyed()) return; + + const changed: TabPublicProperty[] = []; + const wc = this.webContents; + + const newTitle = wc.getTitle() || "New Tab"; + if (this.title !== newTitle) { + this.title = newTitle; + changed.push("title"); + } + + const newUrl = wc.getURL(); + if (this.url !== newUrl) { + this.url = newUrl; + changed.push("url"); + } + + const newIsLoading = wc.isLoading(); + if (this.isLoading !== newIsLoading) { + this.isLoading = newIsLoading; + changed.push("isLoading"); + } + + const newAudible = wc.isCurrentlyAudible(); + if (this.audible !== newAudible) { + this.audible = newAudible; + changed.push("audible"); + } + + const newMuted = wc.isAudioMuted(); + if (this.muted !== newMuted) { + this.muted = newMuted; + changed.push("muted"); + } + + // Nav history diff + const entries = wc.navigationHistory.getAllEntries(); + const currentIndex = wc.navigationHistory.getActiveIndex(); + if (entries.length !== this.lastNavHistoryLength || currentIndex !== this.lastNavHistoryIndex) { + this.navHistory = entries.map((e) => ({ title: e.title || "", url: e.url, pageState: e.pageState })); + this.navHistoryIndex = currentIndex; + this.lastNavHistoryLength = entries.length; + this.lastNavHistoryIndex = currentIndex; + changed.push("navHistory", "navHistoryIndex"); + } + + if (changed.length > 0) { + this.scheduleUpdate(changed); + } + } + + /** + * Polls Chromium's navigation entries for in-place pageState changes + * (scroll position, form values, etc.) that update without events. + * Returns true if any entry was updated. + */ + public pollPageState(): boolean { + if (!this.webContents || this.webContents.isDestroyed() || this.asleep) return false; + + const entries = this.webContents.navigationHistory.getAllEntries(); + if (entries.length !== this.navHistory.length) return false; + + let changed = false; + for (let i = 0; i < entries.length; i++) { + if (entries[i].pageState !== this.navHistory[i].pageState) { + this.navHistory[i] = { ...this.navHistory[i], pageState: entries[i].pageState }; + changed = true; + } + } + + if (changed) { + this.emit("content-changed"); + } + return changed; + } + + private scheduleUpdate(properties: TabPublicProperty[]): void { + for (const prop of properties) { + this._pendingUpdatedProps.add(prop); + } + if (this._updatePending) return; + this._updatePending = true; + queueMicrotask(() => { + this._updatePending = false; + const merged = Array.from(this._pendingUpdatedProps); + this._pendingUpdatedProps.clear(); + if (!this.isDestroyed) { + this.emit("updated", merged); + } + }); + } + + // --- Navigation --- + + public loadURL(url: string): void { + if (!this.webContents || this.webContents.isDestroyed()) return; + this.webContents.loadURL(url).catch(() => { + // Navigation cancelled or failed — ignore + }); + } + + public loadErrorPage(errorCode: number, url: string): void { + const parsedURL = URL.parse(url); + if (parsedURL && parsedURL.protocol === "flow:" && parsedURL.hostname === "error") { + return; // Prevent infinite error page loop + } + + const errorPageURL = new URL("flow://error"); + errorPageURL.searchParams.set("errorCode", errorCode.toString()); + errorPageURL.searchParams.set("url", url); + errorPageURL.searchParams.set("initial", "1"); + + this.loadURL(errorPageURL.toString()); + } + + public restoreNavigationHistory(entries: NavigationEntry[], activeIndex: number): void { + if (!this.webContents || this.webContents.isDestroyed()) return; + + this.webContents.navigationHistory.restore({ + entries: entries.map((e) => ({ url: e.url, title: e.title, pageState: e.pageState })), + index: activeIndex + }); + } + + // --- URL Background --- + + public applyUrlBackground(): void { + if (!this.view) return; + const parsedUrl = URL.parse(this.url); + if (parsedUrl && WHITELISTED_PROTOCOLS.includes(parsedUrl.protocol || "")) { + this.view.setBackgroundColor(COLOR_TRANSPARENT); + } else { + this.view.setBackgroundColor(COLOR_BACKGROUND); + } + } + + // --- History Recording --- + + public markTypedNavigationForNextHistoryVisit(url: string): void { + this.pendingHistoryTypedUrl = url; + } + + public recordBrowsingHistoryOnActivationIfNeeded(): void { + if (!this.webContents || this.webContents.isDestroyed()) return; + const url = this.url; + if (!isHistoryRecordableUrl(url)) return; + + const key = `${url}|${this.title}`; + if (key === this.lastRecordedHistoryKey) return; + this.lastRecordedHistoryKey = key; + + const typed = this.pendingHistoryTypedUrl === url; + if (typed) this.pendingHistoryTypedUrl = null; + + recordBrowsingHistoryVisit({ + profileId: this.profileId, + url, + title: this.title, + incrementTyped: typed + }); + } + + public clearBrowsingHistoryDeduping(url?: string): void { + if (!url) { + this.lastRecordedHistoryKey = ""; + return; + } + if (this.lastRecordedHistoryKey.startsWith(`${url}|`)) { + this.lastRecordedHistoryKey = ""; + } + } + + // --- Lifecycle --- + + public destroy(): void { + if (this.isDestroyed) return; + this.isDestroyed = true; + + // Exit OS fullscreen if this tab is currently fullscreen + if (this.fullScreen) { + const window = this.window; + if (window && !window.destroyed) { + window.browserWindow.setFullScreen(false); + } + } + + // Unregister from extensions (only on destroy, not sleep) + if (this.webContents && !this.webContents.isDestroyed()) { + this.loadedProfile.extensions.removeTab(this.webContents); + } + + this.teardownView(); + this.emit("destroyed"); + this.destroyEmitter(); + } + + // --- Private Listener Setup --- + + private setupWebContentsListeners(): void { + if (!this.webContents) return; + const wc = this.webContents; + + wc.on("did-start-loading", () => this.updateTabState()); + wc.on("did-stop-loading", () => this.updateTabState()); + wc.on("did-start-navigation", () => this.updateTabState()); + wc.on("did-navigate", () => { + this.updateTabState(); + this.applyUrlBackground(); + this.recordBrowsingHistoryOnActivationIfNeeded(); + }); + wc.on("did-navigate-in-page", () => this.updateTabState()); + wc.on("page-title-updated", () => { + this.updateTabState(); + if (isHistoryRecordableUrl(this.url)) { + updateBrowsingHistoryTitleForOpenPage({ + profileId: this.profileId, + url: this.url, + title: this.title + }); + } + }); + wc.on("page-favicon-updated", (_event, favicons) => { + if (favicons.length > 0) { + const newFavicon = favicons[0]; + if (this.faviconURL !== newFavicon) { + this.faviconURL = newFavicon; + this.scheduleUpdate(["faviconURL"]); + cacheFavicon(this.url, newFavicon, this.session); + } + } + }); + wc.on("audio-state-changed", () => this.updateTabState()); + + wc.on("did-fail-load", (event, errorCode, _errorDescription, validatedURL, isMainFrame) => { + event.preventDefault(); + // Skip aborted operations (user navigation cancellations) + if (isMainFrame && errorCode !== -3) { + this.loadErrorPage(errorCode, validatedURL); + } + }); + + wc.on("devtools-open-url", (_event, url) => { + this.emit("new-tab-requested", url, "foreground-tab", undefined, undefined, { noLoadURL: false }); + }); + + wc.on("update-target-url", (_event, url) => { + this.emit("target-url-changed", url); + }); + + wc.on("focus", () => this.emit("focused")); + + // New window/tab requests + wc.setWindowOpenHandler((details) => { + const disposition = details.disposition; + const url = details.url; + + if (disposition === "new-window" || disposition === "foreground-tab" || disposition === "background-tab") { + return { + action: "allow", + outlivesOpener: true, + createWindow: (constructorOptions) => { + const viewOptions = constructorOptions as Electron.WebContentsViewConstructorOptions; + const needsManualLoad = !viewOptions.webContents; + this.emit("new-tab-requested", url, disposition, viewOptions, details, { + noLoadURL: !needsManualLoad + }); + return this._lastCreatedWebContents!; + } + }; + } + + return { action: "allow" }; + }); + + // Fullscreen + wc.on("enter-html-full-screen", () => { + this.setFullScreen(true); + }); + wc.on("leave-html-full-screen", () => { + this.setFullScreen(false); + }); + } +} diff --git a/src/main/controllers/tabs-controller/context-menu.ts b/src/main/services/tab-service/core/web-context-menu.ts similarity index 88% rename from src/main/controllers/tabs-controller/context-menu.ts rename to src/main/services/tab-service/core/web-context-menu.ts index 28b2a009f..f40fcc5eb 100644 --- a/src/main/controllers/tabs-controller/context-menu.ts +++ b/src/main/services/tab-service/core/web-context-menu.ts @@ -1,11 +1,18 @@ +/** + * Web page right-click context menu. + * + * Uses the `electron-context-menu` package to build a rich context menu + * for web content: back/forward/reload, open link in new tab, copy/paste, + * search selection, save image, inspect element, extension items, etc. + */ + import { browserWindowsController } from "@/controllers/windows-controller/interfaces/browser"; import { BrowserWindow } from "@/controllers/windows-controller/types"; import contextMenu from "electron-context-menu"; import { Tab } from "./tab"; -import { TabsController } from "./index"; import { saveImageAs } from "./save-image-as"; +import { tabService } from "../index"; -// Define types for navigation history interface NavigationHistory { canGoBack: () => boolean; canGoForward: () => boolean; @@ -13,7 +20,6 @@ interface NavigationHistory { goForward: () => void; } -// Define interface for menu actions type MenuItemFunction = (options: Record) => Electron.MenuItemConstructorOptions; type InspectFunction = () => Electron.MenuItemConstructorOptions; @@ -31,13 +37,7 @@ interface MenuActions { [key: string]: MenuItemFunction | InspectFunction; } -export function createTabContextMenu( - tabsController: TabsController, - tab: Tab, - profileId: string, - window: BrowserWindow, - spaceId: string -) { +export function createWebContextMenu(tab: Tab, window: BrowserWindow) { const webContents = tab.webContents; if (!webContents) return; @@ -50,19 +50,14 @@ export function createTabContextMenu( const lookUpSelection = defaultActions.lookUpSelection({}); const searchEngine = "Google"; - // Helper function to create a new tab const createNewTab = async (url: string, overrideWindow?: BrowserWindow) => { - const sourceTab = await tabsController.createTab( - overrideWindow ? overrideWindow.id : window.id, - profileId, - spaceId, - undefined, - { url } - ); - tabsController.activateTab(sourceTab); + const targetWindow = overrideWindow ?? tab.getWindow(); + const spaceId = targetWindow.currentSpaceId ?? tab.spaceId; + if (!spaceId) return; + const newTab = await tabService.createTab(targetWindow.id, tab.profileId, spaceId, undefined, { url }); + tabService.activateTab(newTab); }; - // Create all menu sections const openLinkItems = createOpenLinkItems(parameters, createNewTab); const navigationItems = createNavigationItems(navigationHistory, webContents, canGoBack, canGoForward); const extensionItems = createExtensionItems(tab, webContents, parameters); @@ -76,7 +71,6 @@ export function createTabContextMenu( ); const imageItems = createImageItems(parameters, webContents, window, createNewTab, defaultActions as MenuActions); - // Assemble sections in correct order const sections: Electron.MenuItemConstructorOptions[][] = []; const hasDictionarySuggestions = dictionarySuggestions.some((suggestion) => suggestion.visible); if (hasDictionarySuggestions) { @@ -93,10 +87,8 @@ export function createTabContextMenu( } if (hasLink) { sections.push(openLinkItems); - const linkItems = createLinkItems(parameters, webContents, defaultActions, true); sections.push(linkItems); - noSpecialActions = false; } if (parameters.hasImageContents) { @@ -106,7 +98,6 @@ export function createTabContextMenu( if (noSpecialActions) { sections.push(navigationItems); - const linkItems = createLinkItems(parameters, webContents, defaultActions, false); sections.push(linkItems); } @@ -125,7 +116,6 @@ export function createTabContextMenu( const devItems = createDevItems(parameters, defaultActions, createNewTab, noSpecialActions); sections.push(devItems); - // Combine all sections with separators return combineSections(sections, defaultActions as MenuActions); } }); @@ -163,20 +153,17 @@ function createLinkItems( if (hasLink) { const linkURL = parameters.linkURL; - const saveLinkAs: Electron.MenuItemConstructorOptions = { + items.push({ label: "Save Link As...", click: () => { webContents.downloadURL(linkURL); } - }; - items.push(saveLinkAs); + }); const copyLinkItem = defaultActions.copyLink({}); copyLinkItem.label = "Copy Link Address"; copyLinkItem.visible = true; items.push(copyLinkItem); - } else { - // TODO: "Save as..." and "Print" items } return items; @@ -272,8 +259,7 @@ function createSelectionItems( let displaySelectionText = selectionText; if (displaySelectionText.length > 45) { - const newDisplaySelectionText = selectionText.slice(0, 45).trim() + "..."; - displaySelectionText = newDisplaySelectionText; + displaySelectionText = selectionText.slice(0, 45).trim() + "..."; } return [ @@ -345,8 +331,6 @@ function createImageItems( { label: "Save Image As...", click: () => { - // TODO: use a better way - // webContents.saveImageAt - https://github.com/electron/electron/pull/51056 void saveImageAs(parameters, webContents, window); } }, @@ -362,11 +346,8 @@ function combineSections( const combinedSections: Electron.MenuItemConstructorOptions[] = []; sections.forEach((section, index) => { - // Only add non-empty sections if (section.length > 0) { combinedSections.push(...section); - - // Add separator if this isn't the last section if (index < sections.length - 1) { combinedSections.push(defaultActions.separator()); } diff --git a/src/main/services/tab-service/design.md b/src/main/services/tab-service/design.md new file mode 100644 index 000000000..e8429f108 --- /dev/null +++ b/src/main/services/tab-service/design.md @@ -0,0 +1,70 @@ +# Tabs Service v2 + +## Architecture + +### Singleton Services + +- **TabService** - Central orchestrator managing all tabs, layouts, and pinned tabs. +- **TabPersistenceService** - Saves/restores tab state to the database with dirty-tracking and batch flushing. +- **TabIPC** - Handles all renderer communication with debounced structural/content updates. + +### Core Entities + +- **Tab** - A single browser tab. Owns identity, state, WebContentsView, and event emission. The view is nullable (sleeping tabs have no view to save RAM). +- **TabLayoutNode** - Contains tabs displayed together. Modes: `single`, `glance`, `split`. In the old system this was called "TabGroup". +- **PinnedTab** - A persistent URL shortcut linked to a profile. Core component with per-space associations. + +### Layout Management + +- **TabLayout** - One per window. Holds all TabLayoutNodes for that window. Tracks active node, focused tab, and activation history per space. +- **TabPositioner** - Manages tab ordering. Uses floating-point positions for efficient insertion. + +## Key Design Decisions + +### Tab Ownership (TabOwnerRef) + +Every tab has an `owner` field: + +- `{ kind: "normal" }` — Standard tab, persisted independently. +- `{ kind: "pinned", pinnedTabId: string }` — Owned by a PinnedTab. Ephemeral (not persisted independently). +- `{ kind: "bookmark", bookmarkId: string }` — (Future) Owned by a Bookmark. Ephemeral. + +This replaces the old `ephemeral` boolean with a typed, extensible ownership model. + +### Pinned Tabs as Core + +Pinned tabs are first-class citizens, not bolted on. They live inside the TabService and own their associated tabs via the ownership system. + +### Layout Nodes vs Tab Groups + +The old "TabGroup" (glance/split modes) is now "TabLayoutNode" — it represents visual display grouping. +The name "TabGroup" is reserved for future folder-like tab organization (Chrome-style color-coded groups). + +### IPC Channels + +All channels prefixed with `tab-service:` for clean namespacing: + +- `tab-service:get-data` → `WindowTabsPayload` +- `tab-service:on-data-changed` → structural changes +- `tab-service:on-content-updated` → lightweight content-only updates +- `tab-service:pinned-tabs-changed` → pinned tab state changes + +### Persistence + +- Only `normal`-owned tabs are persisted (ephemeral tabs are transient). +- Dirty tracking with batch flush every 2s. +- Immediate persistence for pinned tabs (infrequent changes). + +## QnA + +Q: How are ephemeral tabs handled? +A: Tabs have an `owner` property. When `owner.kind !== "normal"`, the tab is ephemeral and not persisted independently. + +Q: How are tabs saved to the database? +A: TabPersistenceService serializes normal-owned tabs and flushes them periodically. + +Q: How are tabs objects for pinned tabs handled? +A: They are created with `owner: { kind: "pinned", pinnedTabId }` and associated to the PinnedTab entity. + +Q: How will bookmarks work in the future? +A: Same as pinned tabs — create a tab with `owner: { kind: "bookmark", bookmarkId }`. The Bookmark entity will follow the PinnedTab pattern. diff --git a/src/main/services/tab-service/index.ts b/src/main/services/tab-service/index.ts new file mode 100644 index 000000000..f69a8d43d --- /dev/null +++ b/src/main/services/tab-service/index.ts @@ -0,0 +1,62 @@ +/** + * Tab Service v2 — Entry Point + * + * This module exports the singleton TabService instance and related + * components. It is the new architecture for tab management in Flow Browser. + * + * Architecture Overview: + * - TabService: Central orchestrator managing all tabs, layouts, and pinned tabs + * - Tab: Core entity with identity, state, WebContentsView, and events + * - TabLayoutNode: Represents tabs displayed together (single, glance, split) + * - TabLayout: Per-window layout state (active node, focused tab, history) + * - TabPositioner: Manages tab ordering within spaces + * - PinnedTab: Persistent URL shortcut with per-space associations + * - TabPersistenceService: Handles saving/restoring to database + * - TabIPC: Handles all renderer communication + * + * Key differences from old Tab Manager: + * - OOP design with clear ownership + * - "TabGroup" in old system -> "TabLayoutNode" (display grouping) + * - True "TabGroup" reserved for folder-like organization (future) + * - Pinned tabs are a core component with direct Tab ownership + * - Future-proofed for bookmarks via TabOwnerRef + * - Clean IPC layer with debounced updates + * - Separate persistence service + */ + +import { TabService } from "./tab-service"; +import { TabPersistenceService } from "./persistence/tab-persistence-service"; +import { TabIPC } from "./ipc/tab-ipc"; +import { initTabSync } from "./tab-sync"; + +// Export classes +export { TabService } from "./tab-service"; +export { Tab } from "./core/tab"; +export { TabLayoutNode } from "./core/tab-layout-node"; +export { PinnedTab } from "./core/pinned-tab"; +export { TabLayout } from "./layout/tab-layout"; +export { TabPositioner } from "./layout/tab-positioner"; +export { TabPersistenceService } from "./persistence/tab-persistence-service"; +export { TabIPC } from "./ipc/tab-ipc"; + +// Singleton instance +export const tabService = new TabService(); +export const tabPersistenceService = new TabPersistenceService(tabService); +export const tabIPC = new TabIPC(tabService); + +/** + * Initialize the tab service and all its sub-systems. + * Should be called during app startup after the database is ready. + */ +let tabServiceInitialized = false; + +export function initializeTabService(): void { + if (tabServiceInitialized) return; + tabServiceInitialized = true; + + tabService.loadPinnedTabs(); + tabService.startBackgroundTasks(); + tabPersistenceService.start(); + tabIPC.initialize(); + initTabSync(); +} diff --git a/src/main/services/tab-service/ipc/preload-api.ts b/src/main/services/tab-service/ipc/preload-api.ts new file mode 100644 index 000000000..7afe7a573 --- /dev/null +++ b/src/main/services/tab-service/ipc/preload-api.ts @@ -0,0 +1,110 @@ +/** + * Preload API factory for the Tab Service. + * + * This file provides a function that creates the `FlowTabServiceAPI` + * implementation for use in the preload script. It maps IPC channels + * to the API surface defined in shared/flow/interfaces/browser/tab-service.ts. + * + * Usage in preload/index.ts: + * import { createTabServicePreloadAPI } from "@/services/tab-service/ipc/preload-api"; + * const tabServiceAPI = createTabServicePreloadAPI(ipcRenderer, listenOnIPCChannel); + */ + +import type { IpcRenderer } from "electron"; +import type { FlowTabServiceAPI } from "~/flow/interfaces/browser/tab-service"; +import type { + TabData, + WindowTabsPayload, + PinnedTabData, + TabPlaceholderUpdate, + TabTargetUrlUpdate +} from "~/types/tab-service"; + +type ListenFn = (channel: string, callback: (...args: unknown[]) => void) => () => void; + +/** + * Creates the preload API for the tab service. + */ +export function createTabServicePreloadAPI(ipcRenderer: IpcRenderer, listenOnIPCChannel: ListenFn): FlowTabServiceAPI { + return { + // --- Data Queries --- + getData: () => ipcRenderer.invoke("tab-service:get-data"), + + onDataUpdated: (callback: (data: WindowTabsPayload) => void) => { + return listenOnIPCChannel("tab-service:on-data-changed", callback as (...args: unknown[]) => void); + }, + + onContentUpdated: (callback: (tabs: TabData[]) => void) => { + return listenOnIPCChannel("tab-service:on-content-updated", callback as (...args: unknown[]) => void); + }, + + onPlaceholderChanged: (callback: (update: TabPlaceholderUpdate) => void) => { + return listenOnIPCChannel("tab-service:on-placeholder-changed", callback as (...args: unknown[]) => void); + }, + + onTargetUrlChanged: (callback: (update: TabTargetUrlUpdate) => void) => { + return listenOnIPCChannel("tab-service:on-target-url", callback as (...args: unknown[]) => void); + }, + + // --- Tab Operations --- + switchToTab: (tabId: number) => ipcRenderer.invoke("tab-service:switch-to-tab", tabId), + + newTab: (url?: string, isForeground?: boolean, spaceId?: string, typedFromAddressBar?: boolean) => + ipcRenderer.invoke("tab-service:new-tab", url, isForeground, spaceId, typedFromAddressBar), + + closeTab: (tabId: number) => ipcRenderer.invoke("tab-service:close-tab", tabId), + + setTabMuted: (tabId: number, muted: boolean) => ipcRenderer.invoke("tab-service:set-tab-muted", tabId, muted), + + moveTab: (tabId: number, newPosition: number) => ipcRenderer.invoke("tab-service:move-tab", tabId, newPosition), + + moveTabToSpace: (tabId: number, spaceId: string, newPosition?: number) => + ipcRenderer.invoke("tab-service:move-tab-to-space", tabId, spaceId, newPosition), + + batchMoveTabs: (tabIds: number[], spaceId: string, newPositionStart?: number) => + ipcRenderer.invoke("tab-service:batch-move-tabs", tabIds, spaceId, newPositionStart), + + showContextMenu: (tabId: number) => ipcRenderer.send("tab-service:show-context-menu", tabId), + + disablePictureInPicture: (goBackToTab: boolean) => ipcRenderer.invoke("tab-service:disable-pip", goBackToTab), + + // --- Recently Closed --- + getRecentlyClosed: () => ipcRenderer.invoke("tab-service:get-recently-closed"), + + restoreRecentlyClosed: (uniqueId: string) => ipcRenderer.invoke("tab-service:restore-recently-closed", uniqueId), + + clearRecentlyClosed: () => ipcRenderer.invoke("tab-service:clear-recently-closed"), + + // --- Layout Node Operations --- + createLayoutNode: (mode: "glance" | "split", tabIds: number[]) => + ipcRenderer.invoke("tab-service:create-layout-node", mode, tabIds), + + dissolveLayoutNode: (nodeId: string) => ipcRenderer.invoke("tab-service:dissolve-layout-node", nodeId), + + // --- Pinned Tabs --- + getPinnedTabs: () => ipcRenderer.invoke("tab-service:pinned-tabs-get-data"), + + onPinnedTabsChanged: (callback: (data: Record) => void) => { + return listenOnIPCChannel("tab-service:pinned-tabs-changed", callback as (...args: unknown[]) => void); + }, + + createPinnedTabFromTab: (tabId: number, position?: number) => + ipcRenderer.invoke("tab-service:pinned-tabs-create-from-tab", tabId, position), + + clickPinnedTab: (pinnedTabId: string) => ipcRenderer.invoke("tab-service:pinned-tabs-click", pinnedTabId), + + doubleClickPinnedTab: (pinnedTabId: string) => + ipcRenderer.invoke("tab-service:pinned-tabs-double-click", pinnedTabId), + + removePinnedTab: (pinnedTabId: string) => ipcRenderer.invoke("tab-service:pinned-tabs-remove", pinnedTabId), + + unpinToTabList: (pinnedTabId: string, position?: number) => + ipcRenderer.invoke("tab-service:pinned-tabs-unpin", pinnedTabId, position), + + reorderPinnedTab: (pinnedTabId: string, newPosition: number) => + ipcRenderer.invoke("tab-service:pinned-tabs-reorder", pinnedTabId, newPosition), + + showPinnedTabContextMenu: (pinnedTabId: string) => + ipcRenderer.send("tab-service:show-pinned-tab-context-menu", pinnedTabId) + }; +} diff --git a/src/main/services/tab-service/ipc/tab-ipc.ts b/src/main/services/tab-service/ipc/tab-ipc.ts new file mode 100644 index 000000000..4f85a7984 --- /dev/null +++ b/src/main/services/tab-service/ipc/tab-ipc.ts @@ -0,0 +1,578 @@ +import { ipcMain } from "electron"; +import { TabService } from "../tab-service"; +import { browserWindowsController } from "@/controllers/windows-controller/interfaces/browser"; +import { BrowserWindow } from "@/controllers/windows-controller/types"; +import { spacesController } from "@/controllers/spaces-controller"; +import { + TabData, + TabLayoutNodeData, + WindowFocusedTabIds, + WindowActiveLayoutNodeIds, + WindowTabsPayload, + PinnedTabData, + TAB_SERVICE_SCHEMA_VERSION +} from "~/types/tab-service"; +import { Tab } from "../core/tab"; +import { TabLayoutNode } from "../core/tab-layout-node"; +import { PinnedTab } from "../core/pinned-tab"; +import { isTabSyncEnabled, isSyncExcludedTab, isTabSynced } from "../tab-sync"; + +const DEBOUNCE_MS = 32; + +/** + * TabIPC — handles all IPC communication between the TabService and renderer. + * + * Provides: + * - Debounced structural and content change notifications + * - IPC handlers for all tab/pinned-tab operations + */ +export class TabIPC { + private structuralQueue: Set = new Set(); + private contentQueue: Map> = new Map(); + private queueTimeout: NodeJS.Timeout | null = null; + + private pinnedTabChangeTimeout: NodeJS.Timeout | null = null; + + // Serialization cache: tab.id → last serialized TabData + private tabCache: Map = new Map(); + private dirtyTabs: Set = new Set(); + + private readonly tabService: TabService; + + constructor(tabService: TabService) { + this.tabService = tabService; + } + + /** + * Initialize all IPC handlers and event listeners. + */ + public initialize(): void { + this.setupEventListeners(); + this.registerHandlers(); + } + + // --- Event Listeners --- + + private setupEventListeners(): void { + this.tabService.on("structural-change", (windowId) => { + this.enqueueStructuralChange(windowId); + }); + + this.tabService.on("content-change", (windowId, tabId) => { + this.dirtyTabs.add(tabId); + this.enqueueContentChange(windowId, tabId); + }); + + this.tabService.on("tab-removed", (tab) => { + this.tabCache.delete(tab.id); + this.dirtyTabs.delete(tab.id); + }); + + this.tabService.on("pinned-tab-changed", () => { + this.schedulePinnedTabChange(); + }); + } + + private scheduleProcessing(): void { + if (this.queueTimeout) return; + this.queueTimeout = setTimeout(() => { + try { + this.processQueues(); + } finally { + this.queueTimeout = null; + } + }, DEBOUNCE_MS); + } + + /** + * Enqueue a structural change. When tab sync is enabled, all browser + * windows need a refresh because they share the same tab list. + */ + private enqueueStructuralChange(windowId: number): void { + if (isTabSyncEnabled()) { + for (const win of browserWindowsController.getWindows()) { + if (win.browserWindowType === "normal") { + this.structuralQueue.add(win.id); + } + } + } else { + this.structuralQueue.add(windowId); + } + this.scheduleProcessing(); + } + + /** + * Enqueue a content-only change. When tab sync is enabled, non-excluded + * tab changes are broadcast to all windows. Pinned-tab-owned tabs always + * broadcast regardless of sync setting (they are always-sync). + */ + private enqueueContentChange(windowId: number, tabId: number): void { + const tab = this.tabService.getTabById(tabId); + const shouldBroadcast = tab ? isTabSynced(tab) : false; + + if (shouldBroadcast) { + // Broadcast to all normal windows + for (const win of browserWindowsController.getWindows()) { + if (win.browserWindowType !== "normal") continue; + if (this.structuralQueue.has(win.id)) continue; + let tabIds = this.contentQueue.get(win.id); + if (!tabIds) { + tabIds = new Set(); + this.contentQueue.set(win.id, tabIds); + } + tabIds.add(tabId); + } + } else { + // Single-window update (most common path) + if (!this.structuralQueue.has(windowId)) { + let tabIds = this.contentQueue.get(windowId); + if (!tabIds) { + tabIds = new Set(); + this.contentQueue.set(windowId, tabIds); + } + tabIds.add(tabId); + } + } + this.scheduleProcessing(); + } + + private processQueues(): void { + // Structural changes — invalidate cache for all affected windows upfront + // so fields that change without content-change (e.g. lastActiveAt during + // activation) are always fresh. Must evict all before building payloads + // because STAW payloads include tabs from multiple windows. + for (const windowId of this.structuralQueue) { + for (const tab of this.tabService.getTabsInWindow(windowId)) { + this.tabCache.delete(tab.id); + this.dirtyTabs.delete(tab.id); // Will be re-serialized in getWindowTabsPayload + } + } + + for (const windowId of this.structuralQueue) { + const window = browserWindowsController.getWindowById(windowId); + if (!window) continue; + + const payload = this.getWindowTabsPayload(window); + window.sendMessageToCoreWebContents("tab-service:on-data-changed", payload); + this.contentQueue.delete(windowId); + } + this.structuralQueue.clear(); + + // Re-serialize remaining dirty tabs (only those NOT already handled above) + for (const tabId of this.dirtyTabs) { + const tab = this.tabService.getTabById(tabId); + if (tab) { + this.tabCache.set(tabId, this.serializeTabForRenderer(tab)); + } else { + this.tabCache.delete(tabId); + } + } + this.dirtyTabs.clear(); + + // Content-only changes (only send tabs that actually changed) + for (const [windowId, tabIds] of this.contentQueue) { + const window = browserWindowsController.getWindowById(windowId); + if (!window) continue; + + const updatedTabs: TabData[] = []; + for (const tabId of tabIds) { + const cached = this.tabCache.get(tabId); + if (cached) { + updatedTabs.push(cached); + } else { + const tab = this.tabService.getTabById(tabId); + if (!tab) continue; + const data = this.serializeTabForRenderer(tab); + this.tabCache.set(tabId, data); + updatedTabs.push(data); + } + } + + if (updatedTabs.length > 0) { + window.sendMessageToCoreWebContents("tab-service:on-content-updated", updatedTabs); + } + } + this.contentQueue.clear(); + } + + private schedulePinnedTabChange(): void { + if (this.pinnedTabChangeTimeout) clearTimeout(this.pinnedTabChangeTimeout); + this.pinnedTabChangeTimeout = setTimeout(() => { + this.pinnedTabChangeTimeout = null; + const data = this.serializeAllPinnedTabs(); + for (const window of browserWindowsController.getWindows()) { + window.sendMessageToCoreWebContents("tab-service:pinned-tabs-changed", data); + } + }, DEBOUNCE_MS); + } + + // --- IPC Handlers --- + + private registerHandlers(): void { + // --- Tab Data --- + ipcMain.handle("tab-service:get-data", (event) => { + const webContents = event.sender; + const window = browserWindowsController.getWindowFromWebContents(webContents); + if (!window) return null; + return this.getWindowTabsPayload(window); + }); + + // --- Tab Operations --- + ipcMain.handle("tab-service:switch-to-tab", async (event, tabId: number) => { + const tab = this.tabService.getTabById(tabId); + if (!tab) return false; + + const webContents = event.sender; + const requestingWindow = browserWindowsController.getWindowFromWebContents(webContents); + + // If the tab is in a different window, move it to the requesting window first + if (requestingWindow && tab.getWindow().id !== requestingWindow.id) { + if (this.tabService.moveTabToWindowHook) { + await this.tabService.moveTabToWindowHook(tab, requestingWindow); + } else { + this.tabService.ensureNodeInLayout(tab, requestingWindow.id); + tab.setWindow(requestingWindow); + } + } + + this.tabService.activateTab(tab); + return true; + }); + + ipcMain.handle( + "tab-service:new-tab", + async (event, url?: string, isForeground?: boolean, spaceId?: string, typedFromAddressBar?: boolean) => { + const webContents = event.sender; + const window = + browserWindowsController.getWindowFromWebContents(webContents) || browserWindowsController.getWindows()[0]; + if (!window) return false; + + if (!spaceId) { + spaceId = window.currentSpaceId ?? undefined; + } + if (!spaceId) return false; + + const space = await spacesController.get(spaceId); + if (!space) return false; + + const tab = await this.tabService.createTab(window.id, space.profileId, spaceId, undefined, { + url: url || undefined, + typedNavigation: typedFromAddressBar === true + }); + + if (isForeground) { + this.tabService.activateTab(tab); + } + return true; + } + ); + + ipcMain.handle("tab-service:close-tab", async (_event, tabId: number) => { + const tab = this.tabService.getTabById(tabId); + if (!tab) return false; + tab.destroy(); + return true; + }); + + ipcMain.handle("tab-service:set-tab-muted", async (_event, tabId: number, muted: boolean) => { + return this.tabService.setTabMuted(tabId, muted); + }); + + ipcMain.handle("tab-service:move-tab", async (_event, tabId: number, newPosition: number) => { + this.tabService.moveTab(tabId, newPosition); + return true; + }); + + ipcMain.handle( + "tab-service:move-tab-to-space", + async (_event, tabId: number, spaceId: string, newPosition?: number) => { + this.tabService.moveTabToSpace(tabId, spaceId, newPosition); + return true; + } + ); + + // --- Pinned Tabs --- + ipcMain.handle("tab-service:pinned-tabs-get-data", async () => { + return this.serializeAllPinnedTabs(); + }); + + ipcMain.handle("tab-service:pinned-tabs-create-from-tab", async (_event, tabId: number, position?: number) => { + const pinnedTab = this.tabService.createPinnedTabFromTab(tabId, position); + if (!pinnedTab) return null; + return this.serializePinnedTab(pinnedTab); + }); + + ipcMain.handle("tab-service:pinned-tabs-click", async (event, pinnedTabId: string) => { + const webContents = event.sender; + const window = browserWindowsController.getWindowFromWebContents(webContents); + if (!window) return false; + return this.tabService.clickPinnedTab(pinnedTabId, window); + }); + + ipcMain.handle("tab-service:pinned-tabs-double-click", async (event, pinnedTabId: string) => { + const webContents = event.sender; + const window = browserWindowsController.getWindowFromWebContents(webContents); + if (!window) return false; + return this.tabService.doubleClickPinnedTab(pinnedTabId, window); + }); + + ipcMain.handle("tab-service:pinned-tabs-remove", async (_event, pinnedTabId: string) => { + this.tabService.removePinnedTab(pinnedTabId); + return true; + }); + + ipcMain.handle("tab-service:pinned-tabs-unpin", async (event, pinnedTabId: string, position?: number) => { + const webContents = event.sender; + const window = browserWindowsController.getWindowFromWebContents(webContents); + return this.tabService.unpinToTabList(pinnedTabId, window ?? undefined, position); + }); + + ipcMain.handle("tab-service:pinned-tabs-reorder", async (_event, pinnedTabId: string, newPosition: number) => { + this.tabService.reorderPinnedTab(pinnedTabId, newPosition); + return true; + }); + + // --- Layout Nodes --- + ipcMain.handle("tab-service:create-layout-node", async (event, mode: "glance" | "split", tabIds: number[]) => { + const webContents = event.sender; + const window = browserWindowsController.getWindowFromWebContents(webContents); + if (!window) return null; + + const node = this.tabService.createLayoutNode(window.id, mode, tabIds); + if (!node) return null; + + this.tabService.activateNode(window.id, node.spaceId, node); + return this.serializeLayoutNode(node); + }); + + ipcMain.handle("tab-service:dissolve-layout-node", async (event, nodeId: string) => { + const webContents = event.sender; + const window = browserWindowsController.getWindowFromWebContents(webContents); + if (!window) return false; + + this.tabService.dissolveLayoutNode(nodeId, window.id); + return true; + }); + + // --- Context Menu --- + ipcMain.on("tab-service:show-context-menu", (event, tabId: number) => { + const webContents = event.sender; + const window = browserWindowsController.getWindowFromWebContents(webContents); + if (!window) return; + this.tabService.showContextMenu(tabId, window); + }); + + ipcMain.on("tab-service:show-pinned-tab-context-menu", (event, pinnedTabId: string) => { + const webContents = event.sender; + const window = browserWindowsController.getWindowFromWebContents(webContents); + if (!window) return; + this.tabService.showPinnedTabContextMenu(pinnedTabId, window); + }); + + // --- Picture in Picture --- + ipcMain.handle("tab-service:disable-pip", async (event, goBackToTab: boolean) => { + const sender = event.sender; + const tab = this.tabService.getTabByWebContents(sender); + if (!tab) return false; + return this.tabService.disablePictureInPicture(tab.id, goBackToTab); + }); + + // --- Batch Move --- + ipcMain.handle( + "tab-service:batch-move-tabs", + async (event, tabIds: number[], spaceId: string, newPositionStart?: number) => { + const webContents = event.sender; + const window = browserWindowsController.getWindowFromWebContents(webContents); + if (!window) return false; + + const space = await spacesController.get(spaceId); + if (!space) return false; + + return this.tabService.batchMoveTabs(tabIds, spaceId, window, newPositionStart); + } + ); + + // --- Recently Closed --- + ipcMain.handle("tab-service:get-recently-closed", async () => { + return this.tabService.getRecentlyClosed(); + }); + + ipcMain.handle("tab-service:restore-recently-closed", async (event, uniqueId: string) => { + const webContents = event.sender; + const window = browserWindowsController.getWindowFromWebContents(webContents); + if (!window) return false; + return this.tabService.restoreRecentlyClosed(uniqueId, window); + }); + + ipcMain.handle("tab-service:clear-recently-closed", async () => { + this.tabService.clearRecentlyClosed(); + return true; + }); + } + + // --- Serialization --- + + /** + * Build the WindowTabsPayload for a given window. + * + * When "Sync Tabs Across Windows" is enabled, this returns ALL tabs across + * all normal windows (excluding internal-profile/popup tabs from other + * windows). This allows the renderer sidebar to show a unified tab list. + * + * When sync is disabled, only the window's own tabs are returned. + */ + private getWindowTabsPayload(window: BrowserWindow): WindowTabsPayload { + const windowId = window.id; + const syncEnabled = isTabSyncEnabled() && window.browserWindowType === "normal"; + + // Determine which tabs to include + let tabs: Tab[]; + if (syncEnabled) { + tabs = [...this.tabService.tabs.values()].filter((tab) => { + if (tab.getWindow().id === windowId) return true; + return !isSyncExcludedTab(tab); + }); + } else { + tabs = this.tabService.getTabsInWindow(windowId); + } + + // Build tab data and collect spaces/windowIds in a single pass + const tabDatas: TabData[] = new Array(tabs.length); + const spaces = new Set(); + const relevantWindowIds = syncEnabled ? new Set() : undefined; + + for (let i = 0; i < tabs.length; i++) { + const tab = tabs[i]; + spaces.add(tab.spaceId); + if (relevantWindowIds) relevantWindowIds.add(tab.getWindow().id); + + const cached = this.tabCache.get(tab.id); + if (cached) { + tabDatas[i] = cached; + } else { + const data = this.serializeTabForRenderer(tab); + this.tabCache.set(tab.id, data); + tabDatas[i] = data; + } + } + + // Also include spaces from existing layouts for this window. + // Pinned tabs may be propagated into layouts whose spaceId differs from + // tab.spaceId, so tab-derived spaces alone can miss them. + for (const layout of this.tabService.layouts.values()) { + if (layout.windowId === windowId) { + spaces.add(layout.spaceId); + } + } + + // Collect layout nodes from relevant layouts + const layoutNodes: TabLayoutNodeData[] = []; + + if (syncEnabled && relevantWindowIds) { + for (const relWindowId of relevantWindowIds) { + for (const spaceId of spaces) { + const relLayout = this.tabService.getLayout(relWindowId, spaceId); + if (!relLayout) continue; + for (const node of relLayout.getNodes()) { + if (node.mode !== "single") { + layoutNodes.push(this.serializeLayoutNode(node)); + } + } + } + } + } else { + for (const spaceId of spaces) { + const layout = this.tabService.getLayout(windowId, spaceId); + if (!layout) continue; + for (const node of layout.getNodes()) { + if (node.mode !== "single") { + layoutNodes.push(this.serializeLayoutNode(node)); + } + } + } + } + + // Focused and active maps — from this window's per-space layouts + const focusedTabIds: WindowFocusedTabIds = {}; + const activeLayoutNodeIds: WindowActiveLayoutNodeIds = {}; + + for (const spaceId of spaces) { + const layout = this.tabService.getLayout(windowId, spaceId); + if (layout) { + const focusedTab = layout.getFocusedTab(); + if (focusedTab) focusedTabIds[spaceId] = focusedTab.id; + + const activeNode = layout.getActiveNode(); + if (activeNode) activeLayoutNodeIds[spaceId] = activeNode.id; + } + } + + return { + tabs: tabDatas, + layoutNodes, + focusedTabIds, + activeLayoutNodeIds + }; + } + + private serializeTabForRenderer(tab: Tab): TabData { + return { + schemaVersion: TAB_SERVICE_SCHEMA_VERSION, + uniqueId: tab.uniqueId, + createdAt: tab.createdAt, + lastActiveAt: tab.lastActiveAt, + position: tab.position, + profileId: tab.profileId, + spaceId: tab.spaceId, + windowGroupId: `w-${tab.getWindow().id}`, + title: tab.title, + url: tab.url, + faviconURL: tab.faviconURL, + muted: tab.muted, + owner: tab.owner, + + // Runtime fields + id: tab.id, + windowId: tab.getWindow().id, + isLoading: tab.isLoading, + audible: tab.audible, + fullScreen: tab.fullScreen, + isPictureInPicture: tab.isPictureInPicture, + asleep: tab.asleep + }; + } + + private serializeLayoutNode(node: TabLayoutNode): TabLayoutNodeData { + return { + id: node.id, + mode: node.mode, + tabIds: node.tabIds, + frontTabId: node.frontTab?.id, + position: node.position, + spaceId: node.spaceId, + profileId: node.profileId + }; + } + + private serializePinnedTab(pinnedTab: PinnedTab): PinnedTabData { + return { + uniqueId: pinnedTab.uniqueId, + profileId: pinnedTab.profileId, + defaultUrl: pinnedTab.defaultUrl, + faviconUrl: pinnedTab.faviconUrl, + position: pinnedTab.position, + associatedTabIds: pinnedTab.getAssociatedTabIds() + }; + } + + private serializeAllPinnedTabs(): Record { + const byProfile = this.tabService.getAllPinnedTabsByProfile(); + const result: Record = {}; + + for (const [profileId, pinnedTabs] of Object.entries(byProfile)) { + result[profileId] = pinnedTabs.map((pt) => this.serializePinnedTab(pt)); + } + + return result; + } +} diff --git a/src/main/services/tab-service/layout/tab-layout.ts b/src/main/services/tab-service/layout/tab-layout.ts new file mode 100644 index 000000000..77cc2143d --- /dev/null +++ b/src/main/services/tab-service/layout/tab-layout.ts @@ -0,0 +1,383 @@ +import { TypedEventEmitter } from "@/modules/typed-event-emitter"; +import { browserWindowsController } from "@/controllers/windows-controller/interfaces/browser"; +import { Tab } from "../core/tab"; +import { TabLayoutNode } from "../core/tab-layout-node"; +import { TabPositioner } from "./tab-positioner"; +import { TabLayoutNodeMode } from "~/types/tab-service"; + +/** + * TabLayout — one per window-space. + * + * Each TabLayout manages the layout nodes for a single space within + * a single window. At most one layout node is active (visible) at a time. + * + * Responsibilities: + * - Tracks the active layout node + * - Tracks the focused tab (last interacted with) + * - Manages activation history for smart tab switching on close + * - Controls visibility of its managed nodes + * - Delegates position management to TabPositioner + */ + +type TabLayoutEvents = { + "active-changed": [windowId: number, spaceId: string]; + "focused-tab-changed": [windowId: number, spaceId: string]; + "layout-node-created": [TabLayoutNode]; + "layout-node-destroyed": [TabLayoutNode]; + destroyed: []; +}; + +export class TabLayout extends TypedEventEmitter { + public readonly windowId: number; + public readonly spaceId: string; + public readonly positioner: TabPositioner; + public isDestroyed: boolean = false; + + // Active layout node for this layout + private activeNode: TabLayoutNode | null = null; + // Focused tab (last interacted with) + private focusedTab: Tab | null = null; + // Activation history (layout node IDs, most recent last) + private activationHistory: string[] = []; + // All layout nodes in this layout + private layoutNodes: Map = new Map(); + // Index: tabId → node (for O(1) getNodeForTab) + private tabToNode: Map = new Map(); + + private layoutNodeCounter: number = 0; + + /** Whether this layout is currently visible (its space is active in the window). */ + public visible: boolean = false; + + constructor(windowId: number, spaceId: string, positioner: TabPositioner) { + super(); + this.windowId = windowId; + this.spaceId = spaceId; + this.positioner = positioner; + } + + /** + * Set layout visibility. When hidden, all tabs in the active node are hidden. + * When shown, tabs in the active node are made visible. + */ + public setVisible(visible: boolean): void { + if (this.visible === visible) return; + this.visible = visible; + // Actual tab visibility is handled by updateTabVisibility in TabService + // which reads the layout's active node state. + } + + // --- Layout Node Management --- + + /** + * Create a new layout node wrapping a single tab. + */ + public createSingleNode(tab: Tab): TabLayoutNode { + const id = this.generateNodeId(); + const node = new TabLayoutNode(id, "single", tab, this.windowId); + this.registerNode(node); + return node; + } + + /** + * Create a multi-tab layout node (glance or split). + */ + public createMultiNode(mode: Exclude, tabs: Tab[]): TabLayoutNode | null { + if (tabs.length < 2) return null; + + const id = this.generateNodeId(); + const node = new TabLayoutNode(id, mode, tabs[0], this.windowId); + for (let i = 1; i < tabs.length; i++) { + node.addTab(tabs[i]); + } + this.registerNode(node); + return node; + } + + /** + * Get a layout node by ID. + */ + public getNode(nodeId: string): TabLayoutNode | undefined { + return this.layoutNodes.get(nodeId); + } + + /** + * Get all layout nodes in this layout (non-destroyed). + */ + public getNodes(): TabLayoutNode[] { + const result: TabLayoutNode[] = []; + for (const node of this.layoutNodes.values()) { + if (!node.isDestroyed) result.push(node); + } + return result; + } + + /** + * Find the layout node containing a specific tab. + */ + public getNodeForTab(tabId: number): TabLayoutNode | undefined { + return this.tabToNode.get(tabId); + } + + /** + * Get all layout nodes, sorted by position. + */ + public getAllNodesSorted(): TabLayoutNode[] { + return this.getNodes().sort((a, b) => a.position - b.position); + } + + /** + * Destroy a layout node and remove it from tracking. + */ + public destroyNode(nodeId: string): void { + const node = this.layoutNodes.get(nodeId); + if (!node) return; + + this.layoutNodes.delete(nodeId); + this.removeFromHistory(nodeId); + + // Clear active reference if this was active + if (this.activeNode?.id === nodeId) { + this.activeNode = null; + } + + if (!node.isDestroyed) { + node.destroy(); + } + } + + // --- Active Node Management --- + + /** + * Set the active layout node. + */ + public setActiveNode(node: TabLayoutNode): void { + this.activeNode = node; + + // Update history + const existingIdx = this.activationHistory.indexOf(node.id); + if (existingIdx > -1) this.activationHistory.splice(existingIdx, 1); + this.activationHistory.push(node.id); + + // Update focused tab + if (node.frontTab) { + this.setFocusedTab(node.frontTab); + } + + this.emit("active-changed", this.windowId, this.spaceId); + } + + /** + * Get the active layout node. + */ + public getActiveNode(): TabLayoutNode | null { + return this.activeNode; + } + + /** + * Remove active node and select next based on history/position. + */ + public removeActiveAndSelectNext(closedPosition?: number): TabLayoutNode | null { + this.activeNode = null; + this.focusedTab = null; + + // Try from history + for (let i = this.activationHistory.length - 1; i >= 0; i--) { + const node = this.layoutNodes.get(this.activationHistory[i]); + if (node && !node.isDestroyed && node.tabCount > 0) { + this.setActiveNode(node); + return node; + } + } + + // Fall back to position-based + const sorted = this.getAllNodesSorted(); + if (sorted.length === 0) { + this.emit("active-changed", this.windowId, this.spaceId); + return null; + } + + if (closedPosition !== undefined) { + const next = sorted.find((n) => n.position >= closedPosition) ?? sorted[sorted.length - 1]; + this.setActiveNode(next); + return next; + } + + this.setActiveNode(sorted[0]); + return sorted[0]; + } + + /** + * Get the next/previous node without activating it. + */ + public getAdjacentNode(delta: 1 | -1): TabLayoutNode | undefined { + const sorted = this.getAllNodesSorted(); + if (sorted.length === 0) return undefined; + if (sorted.length === 1) return sorted[0]; + + if (!this.activeNode) return sorted[0]; + + const idx = sorted.findIndex((n) => n.id === this.activeNode!.id); + const nextIdx = (idx + delta + sorted.length) % sorted.length; + return sorted[nextIdx]; + } + + /** + * Check if a tab is in the currently active layout node. + */ + public isTabActive(tab: Tab): boolean { + if (!this.activeNode) return false; + return this.activeNode.hasTab(tab.id); + } + + // --- Focused Tab --- + + public setFocusedTab(tab: Tab): void { + this.focusedTab = tab; + this.emit("focused-tab-changed", this.windowId, this.spaceId); + } + + public getFocusedTab(): Tab | null { + return this.focusedTab; + } + + public removeFocusedTab(): void { + this.focusedTab = null; + } + + // --- Lifecycle --- + + public destroy(): void { + if (this.isDestroyed) return; + this.isDestroyed = true; + + for (const node of this.layoutNodes.values()) { + if (!node.isDestroyed) node.destroy(); + } + this.layoutNodes.clear(); + this.activeNode = null; + this.focusedTab = null; + this.activationHistory = []; + this.tabToNode.clear(); + + this.emit("destroyed"); + this.destroyEmitter(); + } + + // --- Private --- + + private generateNodeId(): string { + return `ln-${this.windowId}-${this.spaceId.slice(0, 8)}-${this.layoutNodeCounter++}`; + } + + private registerNode(node: TabLayoutNode): void { + this.layoutNodes.set(node.id, node); + node.addMemberLayout(this); + + // Update tabToNode index + for (const tab of node.tabs) { + this.tabToNode.set(tab.id, node); + } + + // Listen for tab additions/removals to maintain index + node.on("tab-added", (tab) => { + this.tabToNode.set(tab.id, node); + }); + node.on("tab-removed", (tab) => { + this.tabToNode.delete(tab.id); + }); + + node.on("destroyed", () => { + this.layoutNodes.delete(node.id); + this.removeFromHistory(node.id); + node.removeMemberLayout(this); + // Clean up index + for (const tab of node.tabs) { + this.tabToNode.delete(tab.id); + } + if (this.activeNode?.id === node.id) { + this.activeNode = null; + } + this.emit("layout-node-destroyed", node); + }); + + this.emit("layout-node-created", node); + } + + /** + * Add an existing node to this layout (for STAW / pinned tab multi-layout membership). + * Unlike createSingleNode, this doesn't create a new node — it registers an existing one. + */ + public addExistingNode(node: TabLayoutNode): void { + if (this.layoutNodes.has(node.id)) return; + this.registerNode(node); + } + + /** + * Remove a node from this layout without destroying it. + * Used when a node is being unregistered from a secondary layout. + */ + public removeNodeWithoutDestroy(nodeId: string): void { + const node = this.layoutNodes.get(nodeId); + if (!node) return; + + this.layoutNodes.delete(nodeId); + this.removeFromHistory(nodeId); + node.removeMemberLayout(this); + for (const tab of node.tabs) { + this.tabToNode.delete(tab.id); + } + if (this.activeNode?.id === nodeId) { + this.activeNode = null; + } + } + + private removeFromHistory(nodeId: string): void { + const idx = this.activationHistory.indexOf(nodeId); + if (idx > -1) this.activationHistory.splice(idx, 1); + } + + // --- Bounds Calculation (main) --- + + /** + * Compute the main bounds for this layout (the page content area). + * This is the window's page bounds, or fullscreen content size if applicable. + */ + public computeMainBounds(): Electron.Rectangle | null { + const window = browserWindowsController.getWindowById(this.windowId); + if (!window) return null; + return window.pageBounds; + } + + /** + * Apply bounds to all visible tabs in the active node. + * TabLayout computes main bounds, then delegates to TabLayoutNode.computeBounds() + * for per-tab sub-bounds (split/glance). + */ + public applyBounds(): void { + const activeNode = this.activeNode; + if (!activeNode) return; + + const window = browserWindowsController.getWindowById(this.windowId); + if (!window) return; + + const mainBounds = window.pageBounds; + + const tabBoundsMap = activeNode.computeBounds(mainBounds); + for (const [tab, { bounds, layerType }] of tabBoundsMap) { + if (!tab.visible || !tab.view) continue; + + let finalBounds: Electron.Rectangle; + if (tab.fullScreen) { + const [contentWidth, contentHeight] = window.browserWindow.getContentSize(); + finalBounds = { x: 0, y: 0, width: contentWidth, height: contentHeight }; + } else { + finalBounds = bounds; + } + + tab.setLayerType(layerType); + tab.view.setBounds(finalBounds); + tab.view.setBorderRadius(tab.fullScreen ? 0 : 6); + } + } +} diff --git a/src/main/services/tab-service/layout/tab-positioner.ts b/src/main/services/tab-service/layout/tab-positioner.ts new file mode 100644 index 000000000..837a4a1d7 --- /dev/null +++ b/src/main/services/tab-service/layout/tab-positioner.ts @@ -0,0 +1,70 @@ +import { Tab } from "../core/tab"; + +/** + * TabPositioner — manages tab ordering within a space. + * + * Each TabLayout has a TabPositioner, and multiple TabLayouts can share + * the same TabPositioner in Sync Tabs mode. + * + * Position values are floating-point to allow insertion without rewriting + * all positions. Normalization happens periodically or on demand. + */ +export class TabPositioner { + /** + * Get the smallest position among all provided tabs. + */ + public getSmallestPosition(tabs: Tab[]): number { + if (tabs.length === 0) return 0; + return Math.min(...tabs.map((t) => t.position)); + } + + /** + * Get the largest position among all provided tabs. + */ + public getLargestPosition(tabs: Tab[]): number { + if (tabs.length === 0) return 0; + return Math.max(...tabs.map((t) => t.position)); + } + + /** + * Compute a new position for inserting a tab at the top (smallest position). + */ + public getInsertTopPosition(tabs: Tab[]): number { + return this.getSmallestPosition(tabs) - 1; + } + + /** + * Compute a new position for inserting a tab at the bottom (largest position). + */ + public getInsertBottomPosition(tabs: Tab[]): number { + return this.getLargestPosition(tabs) + 1; + } + + /** + * Compute a position for inserting after a specific tab. + */ + public getInsertAfterPosition(tab: Tab, allTabs: Tab[]): number { + const sorted = [...allTabs].sort((a, b) => a.position - b.position); + const index = sorted.findIndex((t) => t.id === tab.id); + + if (index === -1 || index === sorted.length - 1) { + return tab.position + 1; + } + + // Midpoint between current and next + return (tab.position + sorted[index + 1].position) / 2; + } + + /** + * Normalize positions to be sequential integers starting from 0. + * This prevents drift from repeated fractional insertions. + */ + public normalizePositions(tabs: Tab[]): void { + const sorted = [...tabs].sort((a, b) => a.position - b.position); + for (let i = 0; i < sorted.length; i++) { + if (sorted[i].position !== i) { + sorted[i].updateStateProperty("position", i); + } + } + } +} diff --git a/src/main/services/tab-service/persistence/pinned-tab-persistence.ts b/src/main/services/tab-service/persistence/pinned-tab-persistence.ts new file mode 100644 index 000000000..ada822647 --- /dev/null +++ b/src/main/services/tab-service/persistence/pinned-tab-persistence.ts @@ -0,0 +1,49 @@ +import { PinnedTab } from "../core/pinned-tab"; +import { getDb, schema } from "@/saving/db"; +import { eq } from "drizzle-orm"; + +/** + * Handles loading, saving, and deleting pinned tabs from the database. + */ +export class PinnedTabPersistence { + /** + * Load all pinned tab rows from the database. + */ + loadAll(): PinnedTab[] { + const db = getDb(); + const rows = db.select().from(schema.pinnedTabs).all(); + return rows.map((row) => new PinnedTab(row)); + } + + /** + * Upsert a pinned tab into the database. + */ + save(pinnedTab: PinnedTab): void { + const db = getDb(); + db.insert(schema.pinnedTabs) + .values({ + uniqueId: pinnedTab.uniqueId, + profileId: pinnedTab.profileId, + defaultUrl: pinnedTab.defaultUrl, + faviconUrl: pinnedTab.faviconUrl, + position: pinnedTab.position + }) + .onConflictDoUpdate({ + target: schema.pinnedTabs.uniqueId, + set: { + defaultUrl: pinnedTab.defaultUrl, + faviconUrl: pinnedTab.faviconUrl, + position: pinnedTab.position + } + }) + .run(); + } + + /** + * Delete a pinned tab from the database by uniqueId. + */ + delete(uniqueId: string): void { + const db = getDb(); + db.delete(schema.pinnedTabs).where(eq(schema.pinnedTabs.uniqueId, uniqueId)).run(); + } +} diff --git a/src/main/services/tab-service/persistence/tab-persistence-service.ts b/src/main/services/tab-service/persistence/tab-persistence-service.ts new file mode 100644 index 000000000..b9386d873 --- /dev/null +++ b/src/main/services/tab-service/persistence/tab-persistence-service.ts @@ -0,0 +1,393 @@ +import { getDb, schema } from "@/saving/db"; +import { eq } from "drizzle-orm"; +import { + PersistedTabData, + PersistedTabLayoutNodeData, + PersistedWindowState, + TAB_SERVICE_SCHEMA_VERSION, + NavigationEntry +} from "~/types/tab-service"; +import { Tab, SLEEP_MODE_URL } from "../core/tab"; +import { TabLayoutNode } from "../core/tab-layout-node"; +import { TabService } from "../tab-service"; + +const FLUSH_INTERVAL_MS = 2000; + +/** + * Strips sleep mode entries from navigation history. + * These are synthetic entries from older versions. + */ +function stripSleepEntries( + navHistory: NavigationEntry[], + navHistoryIndex: number +): { navHistory: NavigationEntry[]; navHistoryIndex: number } { + const filtered: NavigationEntry[] = []; + let removedBeforeIndex = 0; + + for (let i = 0; i < navHistory.length; i++) { + if (navHistory[i].url === SLEEP_MODE_URL) { + if (i <= navHistoryIndex) removedBeforeIndex++; + continue; + } + filtered.push(navHistory[i]); + } + + let adjustedIndex = navHistoryIndex - removedBeforeIndex; + if (filtered.length === 0) return { navHistory: [], navHistoryIndex: 0 }; + adjustedIndex = Math.max(0, Math.min(adjustedIndex, filtered.length - 1)); + + return { navHistory: filtered, navHistoryIndex: adjustedIndex }; +} + +/** + * TabPersistenceService — handles saving and restoring tabs to/from the database. + * + * Key design: + * - Dirty-tracking: only modified tabs are written + * - Batch flush: all dirty tabs written in a single transaction every ~2s + * - Immediate writes for pinned tabs (change infrequently) + */ +export class TabPersistenceService { + private dirtyTabs = new Map(); + private removedTabs = new Set(); + private dirtyWindowStates = new Map(); + private layoutNodesDirty = false; + private flushInterval: ReturnType | null = null; + private started = false; + + constructor(private readonly tabService: TabService) {} + + // --- Lifecycle --- + + public start(): void { + if (this.started) return; + this.started = true; + + this.flushInterval = setInterval(() => { + this.flush().catch((err) => { + console.error("[TabPersistenceService] Flush failed:", err); + }); + }, FLUSH_INTERVAL_MS); + + // Listen for tab events + this.tabService.on("tab-created", (tab) => this.onTabChanged(tab)); + this.tabService.on("content-change", (_windowId, tabId) => { + const tab = this.tabService.tabs.get(tabId); + if (tab) this.onTabChanged(tab); + }); + this.tabService.on("tab-removed", (tab) => { + if (tab.owner.kind === "normal") { + this.markRemoved(tab.uniqueId); + } + }); + this.tabService.on("structural-change", () => { + this.markLayoutNodesDirty(); + }); + } + + public async stop(): Promise { + if (this.flushInterval) { + clearInterval(this.flushInterval); + this.flushInterval = null; + } + this.started = false; + await this.flush(); + } + + // --- Dirty Tracking --- + + private onTabChanged(tab: Tab): void { + if (tab.owner.kind !== "normal") { + // Ephemeral tabs (pinned/bookmark-owned) are not persisted. + // Remove any stale DB record from when this tab was still "normal". + this.markRemoved(tab.uniqueId); + return; + } + const serialized = this.serializeTab(tab); + this.dirtyTabs.set(tab.uniqueId, serialized); + this.removedTabs.delete(tab.uniqueId); + } + + public markRemoved(uniqueId: string): void { + this.dirtyTabs.delete(uniqueId); + this.removedTabs.add(uniqueId); + } + + public markWindowStateDirty(windowGroupId: string, state: PersistedWindowState): void { + this.dirtyWindowStates.set(windowGroupId, state); + } + + public markLayoutNodesDirty(): void { + this.layoutNodesDirty = true; + } + + // --- Flush --- + + public async flush(): Promise { + if ( + this.dirtyTabs.size === 0 && + this.removedTabs.size === 0 && + this.dirtyWindowStates.size === 0 && + !this.layoutNodesDirty + ) { + return; + } + + // Snapshot and clear atomically before the transaction. + // Any mutations that arrive during the synchronous transaction go into + // fresh maps and are preserved for the next flush cycle. + const dirtySnapshot = new Map(this.dirtyTabs); + this.dirtyTabs.clear(); + const removedSnapshot = new Set(this.removedTabs); + this.removedTabs.clear(); + const windowSnapshot = new Map(this.dirtyWindowStates); + this.dirtyWindowStates.clear(); + const layoutNodesDirtySnapshot = this.layoutNodesDirty; + this.layoutNodesDirty = false; + + try { + const db = getDb(); + db.transaction((tx) => { + // Upsert dirty tabs + for (const [, data] of dirtySnapshot) { + const insert = this.persistedDataToInsert(data); + tx.insert(schema.tabs) + .values(insert) + .onConflictDoUpdate({ + target: schema.tabs.uniqueId, + set: insert + }) + .run(); + } + + // Remove deleted tabs + for (const uniqueId of removedSnapshot) { + tx.delete(schema.tabs).where(eq(schema.tabs.uniqueId, uniqueId)).run(); + } + + // Upsert window states + for (const [windowGroupId, state] of windowSnapshot) { + const insert = { + windowGroupId, + width: state.width, + height: state.height, + x: state.x ?? null, + y: state.y ?? null, + isPopup: state.isPopup ?? null + }; + tx.insert(schema.windowStates) + .values(insert) + .onConflictDoUpdate({ + target: schema.windowStates.windowGroupId, + set: insert + }) + .run(); + } + + if (layoutNodesDirtySnapshot) { + tx.delete(schema.tabGroups).run(); + + for (const node of this.getPersistableLayoutNodes()) { + const data = this.serializeLayoutNode(node); + tx.insert(schema.tabGroups) + .values({ + groupId: data.id, + mode: data.mode, + profileId: data.profileId, + spaceId: data.spaceId, + tabUniqueIds: data.tabUniqueIds, + glanceFrontTabUniqueId: data.frontTabUniqueId ?? null, + position: data.position + }) + .run(); + } + } + }); + } catch (err) { + // Re-queue entries that haven't been superseded by newer mutations + for (const [uniqueId, data] of dirtySnapshot) { + if (!this.dirtyTabs.has(uniqueId)) { + this.dirtyTabs.set(uniqueId, data); + } + } + for (const uniqueId of removedSnapshot) { + if (!this.removedTabs.has(uniqueId) && !this.dirtyTabs.has(uniqueId)) { + this.removedTabs.add(uniqueId); + } + } + for (const [windowGroupId, state] of windowSnapshot) { + if (!this.dirtyWindowStates.has(windowGroupId)) { + this.dirtyWindowStates.set(windowGroupId, state); + } + } + if (layoutNodesDirtySnapshot) { + this.layoutNodesDirty = true; + } + throw err; + } + } + + // --- Load --- + + public loadAllTabs(): PersistedTabData[] { + const db = getDb(); + const rows = db.select().from(schema.tabs).all(); + return rows.map((row) => this.rowToPersistedData(row)); + } + + public loadAllLayoutNodes(): PersistedTabLayoutNodeData[] { + const db = getDb(); + const rows = db.select().from(schema.tabGroups).all(); + return rows.map((row) => ({ + id: row.groupId, + mode: row.mode as Exclude, + tabUniqueIds: row.tabUniqueIds, + frontTabUniqueId: row.glanceFrontTabUniqueId ?? undefined, + position: row.position, + spaceId: row.spaceId, + profileId: row.profileId + })); + } + + public loadAllWindowStates(): Map { + const db = getDb(); + const rows = db.select().from(schema.windowStates).all(); + const result = new Map(); + for (const row of rows) { + result.set(row.windowGroupId, { + width: row.width, + height: row.height, + x: row.x ?? undefined, + y: row.y ?? undefined, + isPopup: row.isPopup ?? undefined + }); + } + return result; + } + + // --- Remove --- + + public removeTab(uniqueId: string): void { + const db = getDb(); + db.delete(schema.tabs).where(eq(schema.tabs.uniqueId, uniqueId)).run(); + } + + public removeLayoutNode(nodeId: string): void { + const db = getDb(); + db.delete(schema.tabGroups).where(eq(schema.tabGroups.groupId, nodeId)).run(); + } + + // --- Serialization --- + + public serializeTab(tab: Tab): PersistedTabData { + const url = tab.url; + const rawNavHistory = tab.navHistory; + const rawNavHistoryIndex = tab.navHistoryIndex; + + const { navHistory, navHistoryIndex } = stripSleepEntries(rawNavHistory, rawNavHistoryIndex); + + return { + schemaVersion: TAB_SERVICE_SCHEMA_VERSION, + uniqueId: tab.uniqueId, + createdAt: tab.createdAt, + lastActiveAt: tab.lastActiveAt, + position: tab.position, + profileId: tab.profileId, + spaceId: tab.spaceId, + windowGroupId: `w-${tab.getWindow().id}`, + title: tab.title, + url, + faviconURL: tab.faviconURL, + muted: tab.muted, + navHistory, + navHistoryIndex, + owner: tab.owner + }; + } + + public serializeLayoutNode(node: TabLayoutNode): PersistedTabLayoutNodeData { + const tabUniqueIds: string[] = []; + for (const tab of node.tabs) { + tabUniqueIds.push(tab.uniqueId); + } + + return { + id: node.id, + mode: node.mode as Exclude, + tabUniqueIds, + frontTabUniqueId: node.frontTab?.uniqueId, + position: node.position, + spaceId: node.spaceId, + profileId: node.profileId + }; + } + + // --- Private --- + + private getPersistableLayoutNodes(): TabLayoutNode[] { + const nodes = new Map(); + for (const layout of this.tabService.layouts.values()) { + for (const node of layout.getNodes()) { + if (node.mode === "single") continue; + if (node.isDestroyed || node.tabCount < 2) continue; + if (!node.tabs.every((tab) => tab.owner.kind === "normal")) continue; + nodes.set(node.id, node); + } + } + return [...nodes.values()]; + } + + private persistedDataToInsert(data: PersistedTabData) { + return { + uniqueId: data.uniqueId, + schemaVersion: data.schemaVersion, + createdAt: data.createdAt, + lastActiveAt: data.lastActiveAt, + position: data.position, + profileId: data.profileId, + spaceId: data.spaceId, + windowGroupId: data.windowGroupId, + title: data.title, + url: data.url, + faviconUrl: data.faviconURL, + muted: data.muted, + navHistory: data.navHistory, + navHistoryIndex: data.navHistoryIndex + }; + } + + private rowToPersistedData(row: { + uniqueId: string; + schemaVersion: number; + createdAt: number; + lastActiveAt: number; + position: number; + profileId: string; + spaceId: string; + windowGroupId: string; + title: string; + url: string; + faviconUrl: string | null; + muted: boolean; + navHistory: NavigationEntry[]; + navHistoryIndex: number; + }): PersistedTabData { + return { + schemaVersion: row.schemaVersion, + uniqueId: row.uniqueId, + createdAt: row.createdAt, + lastActiveAt: row.lastActiveAt, + position: row.position, + profileId: row.profileId, + spaceId: row.spaceId, + windowGroupId: row.windowGroupId, + title: row.title, + url: row.url, + faviconURL: row.faviconUrl, + muted: row.muted, + navHistory: row.navHistory, + navHistoryIndex: row.navHistoryIndex, + owner: { kind: "normal" } + }; + } +} diff --git a/src/main/services/tab-service/tab-lifecycle-timer.ts b/src/main/services/tab-service/tab-lifecycle-timer.ts new file mode 100644 index 000000000..d8a95b7c1 --- /dev/null +++ b/src/main/services/tab-service/tab-lifecycle-timer.ts @@ -0,0 +1,50 @@ +import { Tab } from "./core/tab"; +import { quitController } from "@/controllers/quit-controller"; +import { getSettingValueById } from "@/saving/settings"; +import { ArchiveTabValueMap, SleepTabValueMap } from "@/modules/basic-settings"; + +/** + * Periodically checks inactive tabs and: + * - Archives (destroys) tabs inactive beyond the archive threshold + * - Puts tabs to sleep once they exceed the sleep threshold + * + * Interval: 10 seconds. Only processes normal (non-pinned, non-bookmark) tabs + * that are not currently visible. + */ +export function startTabLifecycleTimer(tabs: Map): void { + setInterval(() => { + if (quitController.isQuitting) return; + + // Poll pageState on all awake tabs (scroll position, form data, etc.) + for (const tab of tabs.values()) { + tab.pollPageState(); + } + + const nowSec = Math.floor(Date.now() / 1000); + + // Read settings once per tick (not per tab) + const archiveAfter = getSettingValueById("archiveTabAfter"); + const archiveSec = + typeof archiveAfter === "string" ? (ArchiveTabValueMap[archiveAfter as keyof typeof ArchiveTabValueMap] ?? 0) : 0; + + const sleepAfter = getSettingValueById("sleepTabAfter"); + const sleepSec = + typeof sleepAfter === "string" ? (SleepTabValueMap[sleepAfter as keyof typeof SleepTabValueMap] ?? 0) : 0; + + for (const tab of tabs.values()) { + if (tab.owner.kind !== "normal") continue; + if (tab.visible) continue; + + // Auto-archive (destroy) tabs inactive too long + if (archiveSec > 0 && nowSec - tab.lastActiveAt >= archiveSec) { + tab.destroy(); + continue; + } + + // Auto-sleep tabs inactive past threshold + if (!tab.asleep && sleepSec > 0 && nowSec - tab.lastActiveAt >= sleepSec) { + tab.putToSleep(); + } + } + }, 10_000); +} diff --git a/src/main/services/tab-service/tab-service.ts b/src/main/services/tab-service/tab-service.ts new file mode 100644 index 000000000..b062d3f07 --- /dev/null +++ b/src/main/services/tab-service/tab-service.ts @@ -0,0 +1,1885 @@ +import { TypedEventEmitter } from "@/modules/typed-event-emitter"; +import { Tab, TabCreationDetails, TabCreationOptions } from "./core/tab"; +import { TabLayoutNode } from "./core/tab-layout-node"; +import { PinnedTab } from "./core/pinned-tab"; +import { RecentlyClosedManager } from "./core/recently-closed-manager"; +import { showTabContextMenu, showPinnedTabContextMenu } from "./core/tab-context-menus"; +import { TabLayout } from "./layout/tab-layout"; +import { TabPositioner } from "./layout/tab-positioner"; +import { PinnedTabPersistence } from "./persistence/pinned-tab-persistence"; +import { startTabLifecycleTimer } from "./tab-lifecycle-timer"; +import { + NavigationEntry, + PersistedTabData, + RecentlyClosedTabData, + TabLayoutNodeMode, + TAB_SERVICE_SCHEMA_VERSION +} from "~/types/tab-service"; +import { browserWindowsController } from "@/controllers/windows-controller/interfaces/browser"; +import { spacesController } from "@/controllers/spaces-controller"; +import { loadedProfilesController } from "@/controllers/loaded-profiles-controller"; +import { BrowserWindow } from "@/controllers/windows-controller/types"; +import { WebContents } from "electron"; +import { quitController } from "@/controllers/quit-controller"; +import { setWindowSpace } from "@/ipc/session/spaces"; +import { FLAGS } from "@/modules/flags"; +import { sendPlaceholderForTab } from "./tab-sync"; + +export const NEW_TAB_URL = "flow://new-tab"; + +type TabServiceEvents = { + "tab-created": [Tab]; + "tab-removed": [Tab]; + "active-changed": [windowId: number, spaceId: string]; + "focused-tab-changed": [windowId: number, spaceId: string]; + "pinned-tab-changed": []; + "structural-change": [windowId: number]; + "content-change": [windowId: number, tabId: number]; + destroyed: []; +}; + +/** + * TabService — the central orchestrator for tab management. + * + * Manages: + * - All tabs (Map) + * - All pinned tabs (Map) + * - Per-window-space layouts (Map<`${windowId}-${spaceId}`, TabLayout>) + * - A shared TabPositioner + * + * Coordinates tab creation, destruction, activation, pinned tab operations, + * and communication with the renderer via events. + */ +export class TabService extends TypedEventEmitter { + // All tabs + public readonly tabs: Map = new Map(); + + // Per-window-space layouts (key: `${windowId}-${spaceId}`) + public readonly layouts: Map = new Map(); + + // Pinned tabs + public readonly pinnedTabs: Map = new Map(); + + // Recently closed + public readonly recentlyClosed: RecentlyClosedManager = new RecentlyClosedManager(); + + // Shared positioner + public readonly positioner: TabPositioner = new TabPositioner(); + + // --- Indexes for O(1) lookups --- + private readonly windowIndex: Map> = new Map(); + private readonly spaceIndex: Map> = new Map(); + private readonly webContentsIndex: WeakMap = new WeakMap(); + + // PiP counter — avoids iterating all tabs to check if any is in PiP + private _pipCount: number = 0; + + // Emission suppression for batch operations (e.g., session restore). + // While > 0, structural/content emissions are deferred. + private _suppressEmissions: number = 0; + private _deferredStructural: Set = new Set(); + + /** + * Hook for tab-sync: moves a tab to another window with placeholder handling. + * Set by initTabSync() to avoid circular dependency. + */ + public moveTabToWindowHook: ((tab: Tab, window: BrowserWindow) => Promise) | null = null; + + // Persistence delegate + private readonly pinnedTabDb = new PinnedTabPersistence(); + + // --- Initialization --- + + /** + * Load all pinned tabs from the database into memory. + * Called once during app startup. + */ + public loadPinnedTabs(): void { + const pinnedTabs = this.pinnedTabDb.loadAll(); + for (const pinnedTab of pinnedTabs) { + this.pinnedTabs.set(pinnedTab.uniqueId, pinnedTab); + this.wirePinnedTabEvents(pinnedTab); + } + } + + /** + * Start background tasks: space-deletion cleanup & auto-sleep/archive timer. + * Called once during initialization. + */ + public startBackgroundTasks(): void { + spacesController.on("space-deleted", (_profileId, spaceId) => { + if (quitController.isQuitting) return; + for (const tab of this.getTabsInSpace(spaceId)) { + tab.destroy(); + } + }); + + startTabLifecycleTimer(this.tabs); + } + + // --- Tab Creation --- + + /** + * Create a new tab with automatic window/profile/space resolution. + */ + public async createTab( + windowId?: number, + profileId?: string, + spaceId?: string, + webContentsViewOptions?: Electron.WebContentsViewConstructorOptions, + options: Partial = {} + ): Promise { + // Resolve window + if (!windowId) { + const focusedWindow = browserWindowsController.getFocusedWindow(); + if (focusedWindow) { + windowId = focusedWindow.id; + } else { + const windows = browserWindowsController.getWindows(); + if (windows.length > 0) { + windowId = windows[0].id; + } else { + throw new Error("No window available for new tab"); + } + } + } + + // Resolve profile/space from window + if (!profileId || !spaceId) { + const window = browserWindowsController.getWindowById(windowId); + if (window?.currentSpaceId) { + const spaceData = await spacesController.get(window.currentSpaceId); + if (spaceData) { + profileId = profileId || spaceData.profileId; + spaceId = spaceId || window.currentSpaceId; + } + } + } + + // Fallback to last used space + if (!profileId) { + const lastUsedSpace = await spacesController.getLastUsed(); + if (lastUsedSpace) { + profileId = lastUsedSpace.profileId; + spaceId = spaceId || lastUsedSpace.id; + } else { + throw new Error("Could not determine profile for new tab"); + } + } else if (!spaceId) { + const lastUsedSpace = await spacesController.getLastUsedFromProfile(profileId); + if (lastUsedSpace) { + spaceId = lastUsedSpace.id; + } else { + throw new Error("Could not determine space for new tab"); + } + } + + // Load profile + await loadedProfilesController.load(profileId); + + const window = browserWindowsController.getWindowById(windowId); + if (window && !window.currentSpaceId) { + window.setCurrentSpace(spaceId!); + } + + return this.createTabInternal(windowId, profileId, spaceId!, webContentsViewOptions, options); + } + + /** + * Internal tab creation — assumes all parameters are resolved. + */ + public createTabInternal( + windowId: number, + profileId: string, + spaceId: string, + webContentsViewOptions?: Electron.WebContentsViewConstructorOptions, + options: Partial = {} + ): Tab { + const window = browserWindowsController.getWindowById(windowId); + if (!window) throw new Error("Window not found"); + + const profile = loadedProfilesController.get(profileId); + if (!profile) throw new Error("Profile not found"); + + // Compute position if not provided + if (options.position === undefined) { + const tabsInSpace = this.getTabsInWindowSpace(windowId, spaceId); + options.position = this.positioner.getInsertTopPosition(tabsInSpace); + } + + // Create the tab + const details: TabCreationDetails = { + profileId, + spaceId, + session: profile.session, + loadedProfile: profile + }; + + const tab = new Tab(details, { + window, + webContentsViewOptions, + ...options + } as TabCreationOptions); + + // Register tab and update indexes + this.tabs.set(tab.id, tab); + this.addToIndex(this.windowIndex, tab.getWindow().id, tab); + this.addToIndex(this.spaceIndex, tab.spaceId, tab); + if (tab.webContents) this.webContentsIndex.set(tab.webContents, tab); + + // Get or create layout for this window-space + const layout = this.getOrCreateLayout(windowId, spaceId!); + + // Create a single layout node for this tab + layout.createSingleNode(tab); + + // Wire up tab events + this.wireTabEvents(tab); + + // Activate the new tab unless explicitly suppressed or created asleep + if (options.makeActive !== false && !tab.asleep) { + this.activateTab(tab); + } + + // Load initial URL if needed + if (tab._needsInitialLoad && options.noLoadURL !== true) { + const initialURL = options.url || profile.newTabUrl || NEW_TAB_URL; + if (options.typedNavigation) { + tab.markTypedNavigationForNextHistoryVisit(initialURL); + } + tab.loadURL(initialURL); + } + + this.emit("tab-created", tab); + this.emitStructuralChange(windowId); + + // Notify extensions that indices changed for all tabs in the same window+profile + this.notifyIndexChanges(windowId, profileId); + + return tab; + } + + // --- Tab Destruction --- + + /** + * Remove and clean up a tab. + */ + public destroyTab(tabId: number): void { + const tab = this.tabs.get(tabId); + if (!tab) return; + + tab.destroy(); + } + + // --- Tab Queries --- + + public getTabById(tabId: number): Tab | undefined { + return this.tabs.get(tabId); + } + + public getTabByWebContents(webContents: WebContents): Tab | undefined { + return this.webContentsIndex.get(webContents); + } + + public getTabsInWindow(windowId: number): Tab[] { + const set = this.windowIndex.get(windowId); + return set ? Array.from(set) : []; + } + + public getTabsInSpace(spaceId: string): Tab[] { + const set = this.spaceIndex.get(spaceId); + return set ? Array.from(set) : []; + } + + public getTabsInWindowSpace(windowId: number, spaceId: string): Tab[] { + // Use the smaller index as the base for intersection + const windowSet = this.windowIndex.get(windowId); + const spaceSet = this.spaceIndex.get(spaceId); + if (!windowSet || !spaceSet) return []; + + const result: Tab[] = []; + const smaller = windowSet.size <= spaceSet.size ? windowSet : spaceSet; + const larger = smaller === windowSet ? spaceSet : windowSet; + for (const tab of smaller) { + if (larger.has(tab)) result.push(tab); + } + return result; + } + + public getTabsInProfile(profileId: string): Tab[] { + const result: Tab[] = []; + for (const tab of this.tabs.values()) { + if (tab.profileId === profileId) result.push(tab); + } + return result; + } + + public getTabsInWindowProfile(windowId: number, profileId: string): Tab[] { + return this.getTabsInWindow(windowId) + .filter((tab) => tab.profileId === profileId) + .sort((a, b) => a.position - b.position); + } + + public getTabIndexInWindowProfile(tab: Tab): number { + return this.getTabsInWindowProfile(tab.getWindow().id, tab.profileId).findIndex( + (candidate) => candidate.id === tab.id + ); + } + + public clearBrowsingHistoryDedupingForProfile(profileId: string, url?: string): void { + for (const tab of this.getTabsInProfile(profileId)) { + tab.clearBrowsingHistoryDeduping(url); + } + } + + // --- Active Tab Management --- + + /** + * Activate a layout node (makes it visible). + */ + public activateNode(windowId: number, spaceId: string, node: TabLayoutNode): void { + const layout = this.getLayout(windowId, spaceId); + if (!layout) return; + + layout.setActiveNode(node); + + // Update view visibility and bounds + this.updateTabVisibility(windowId, spaceId); + this.handlePageBoundsChanged(windowId); + + // Notify renderer of active node change + this.emitStructuralChange(windowId); + } + + /** + * Activate a tab by finding its layout node and making it active. + * Wakes the tab if it is sleeping so the view is available. + */ + private _activatingTabIds = new Set(); + + public activateTab(tab: Tab): void { + // Guard against re-entry (extensions.addTab can fire selectTab → activateTab) + if (this._activatingTabIds.has(tab.id)) return; + + const windowId = tab.getWindow().id; + const window = browserWindowsController.getWindowById(windowId); + + // For pinned tabs (multi-layout nodes), prefer the window's current space layout + // since pinned nodes span all profile spaces and tab.spaceId is just the creation space. + let layout: TabLayout | undefined; + if (window?.currentSpaceId) { + const currentLayout = this.getLayout(windowId, window.currentSpaceId); + if (currentLayout?.getNodeForTab(tab.id)) { + layout = currentLayout; + } + } + if (!layout) { + layout = this.getLayout(windowId, tab.spaceId); + } + if (!layout) return; + + const node = layout.getNodeForTab(tab.id); + if (!node) return; + + this._activatingTabIds.add(tab.id); + try { + // Wake sleeping tabs so the view exists for display + if (tab.asleep) { + tab.wakeUp(); + } + + // For multi-tab nodes (glance), set front tab + if (node.mode === "glance") { + node.setFrontTab(tab); + } + + layout.setActiveNode(node); + layout.setFocusedTab(tab); + + // Mark as recently active (prevents premature archive/sleep) + tab.lastActiveAt = Math.floor(Date.now() / 1000); + + // Only update visibility/bounds if this layout's space is the window's current space. + // A tab can be activated in a non-current space (e.g. STAW release) without + // making it visible — it becomes visible when the user switches to that space. + if (window && !window.destroyed && window.currentSpaceId === layout.spaceId) { + this.updateTabVisibility(windowId, layout.spaceId); + this.handlePageBoundsChanged(windowId); + } + + // Record browsing history on activation (deduped) + tab.recordBrowsingHistoryOnActivationIfNeeded(); + + // Notify extensions of the active tab change + if (tab.webContents && !tab.webContents.isDestroyed()) { + tab.loadedProfile.extensions.selectTab(tab.webContents); + } + + // Focus the tab's layer through the LayerManager — but only if the + // window is currently focused. Calling webContents.focus() on a + // background window would steal OS focus (same issue reallocateFocus + // defers to avoid). When the window later gains focus, the deferred + // reallocateFocus handles it. + if (tab.layer && window && !window.destroyed && window.browserWindow.isFocused()) { + tab.layer.focus(); + } + + // Notify renderer of active tab change + this.emitStructuralChange(windowId); + } finally { + this._activatingTabIds.delete(tab.id); + } + } + + /** + * Ensure a tab's node exists in the target window's current-space layout and + * set it as the activeLayout (for STAW cross-window moves). + * + * With multi-layout membership, nodes are never destroyed during cross-window + * moves. The node stays registered in the source layout (which shows a + * placeholder) and is registered in the target layout (which shows real content). + * + * For pinned tabs the node already exists in all profile layouts via propagation, + * so this just flips activeLayout. For normal STAW tabs the node is registered + * in the target layout if not already present. + */ + public ensureNodeInLayout(tab: Tab, toWindowId: number): void { + const fromWindowId = tab.getWindow().id; + if (fromWindowId === toWindowId) return; + + const targetWindow = browserWindowsController.getWindowById(toWindowId); + const targetSpaceId = targetWindow?.currentSpaceId ?? tab.spaceId; + const toLayout = this.getOrCreateLayout(toWindowId, targetSpaceId); + + // Find the node: try the target layout first (pinned tabs are already there), + // then fall back to looking up from the source window's layout. + let node = toLayout.getNodeForTab(tab.id); + if (!node) { + const fromLayout = this.getLayout(fromWindowId, tab.spaceId); + node = fromLayout?.getNodeForTab(tab.id); + if (node) { + toLayout.addExistingNode(node); + } + } + + if (node) { + node.setActiveLayout(toLayout); + } else { + // No node found anywhere — create fresh in target + toLayout.createSingleNode(tab); + } + } + + /** + * Activate the next tab in visual order. + */ + public activateNextTab(windowId: number, spaceId: string): void { + const layout = this.getLayout(windowId, spaceId); + if (!layout) return; + const node = layout.getAdjacentNode(1); + if (node?.frontTab) { + this.activateTab(node.frontTab); + } + } + + /** + * Activate the previous tab in visual order. + */ + public activatePreviousTab(windowId: number, spaceId: string): void { + const layout = this.getLayout(windowId, spaceId); + if (!layout) return; + const node = layout.getAdjacentNode(-1); + if (node?.frontTab) { + this.activateTab(node.frontTab); + } + } + + /** + * Check if a tab is currently active in any layout of its window. + */ + public isTabActive(tab: Tab): boolean { + const windowId = tab.getWindow().id; + for (const layout of this.layouts.values()) { + if (layout.windowId !== windowId) continue; + if (layout.isTabActive(tab)) return true; + } + return false; + } + + /** + * Get the focused tab for a space in a window. + */ + public getFocusedTab(windowId: number, spaceId: string): Tab | undefined { + return this.getLayout(windowId, spaceId)?.getFocusedTab() ?? undefined; + } + + /** + * Get the active layout node for a space in a window. + */ + public getActiveNode(windowId: number, spaceId: string): TabLayoutNode | undefined { + return this.getLayout(windowId, spaceId)?.getActiveNode() ?? undefined; + } + + // --- Layout Node Operations --- + + /** + * Create a multi-tab layout node (e.g., glance or split). + */ + public createLayoutNode( + windowId: number, + mode: Exclude, + tabIds: number[] + ): TabLayoutNode | null { + const tabs = tabIds.map((id) => this.tabs.get(id)).filter((t): t is Tab => !!t); + if (tabs.length < 2) return null; + + const spaceId = tabs[0].spaceId; + if (!tabs.every((tab) => tab.spaceId === spaceId && tab.getWindow().id === windowId)) { + return null; + } + + const layout = this.getLayout(windowId, spaceId); + if (!layout) return null; + + // Remove tabs from their current single nodes + for (const tab of tabs) { + const existingNode = layout.getNodeForTab(tab.id); + if (existingNode && existingNode.mode === "single") { + layout.destroyNode(existingNode.id); + } else if (existingNode) { + existingNode.removeTab(tab); + } + } + + const node = layout.createMultiNode(mode, tabs); + if (node) { + this.emitStructuralChange(windowId); + } + return node; + } + + /** + * Dissolve a layout node back to individual single nodes. + */ + public dissolveLayoutNode(nodeId: string, windowId: number): void { + // Find the layout containing this node + const layout = this.findLayoutWithNode(nodeId, windowId); + if (!layout) return; + + const node = layout.getNode(nodeId); + if (!node || node.mode === "single") return; + + const tabs = [...node.tabs]; + layout.destroyNode(nodeId); + + // Create individual nodes for each tab + for (const tab of tabs) { + layout.createSingleNode(tab); + } + + // Activate the first tab + if (tabs.length > 0) { + this.activateTab(tabs[0]); + } + } + + /** + * Find a layout in a window that contains a specific node. + */ + private findLayoutWithNode(nodeId: string, windowId: number): TabLayout | undefined { + for (const layout of this.getLayoutsForWindow(windowId)) { + if (layout.getNode(nodeId)) return layout; + } + return undefined; + } + + // --- Pinned Tabs --- + + /** + * Create a pinned tab from an existing browser tab. + */ + public createPinnedTabFromTab(tabId: number, position?: number): PinnedTab | null { + const tab = this.tabs.get(tabId); + if (!tab || !tab.url) return null; + + const maxPos = this.getMaxPinnedTabPosition(tab.profileId); + const finalPosition = position ?? maxPos + 1; + + const pinnedTab = PinnedTab.create(tab.profileId, tab.url, tab.faviconURL, finalPosition); + + this.pinnedTabs.set(pinnedTab.uniqueId, pinnedTab); + + // Mark the tab as owned by this pinned tab (ephemeral — remove stale DB record) + tab.owner = { kind: "pinned", pinnedTabId: pinnedTab.uniqueId }; + this.emitContentChange(tab.getWindow().id, tab.id); + + // Associate the tab + pinnedTab.associate(tab.spaceId, tab.id); + + const layout = this.getLayout(tab.getWindow().id, tab.spaceId); + const node = layout?.getNodeForTab(tab.id); + if (node) { + pinnedTab.layoutNode = node; + this.propagatePinnedTabNode(node, pinnedTab.profileId); + } + + this.wirePinnedTabEvents(pinnedTab); + this.normalizePinnedTabPositions(tab.profileId); + this.pinnedTabDb.save(pinnedTab); + this.emit("pinned-tab-changed"); + this.emitStructuralChange(tab.getWindow().id); + + return pinnedTab; + } + + /** + * Remove a pinned tab. Associated tabs become normal. + */ + public removePinnedTab(pinnedTabId: string): number[] { + const pinnedTab = this.pinnedTabs.get(pinnedTabId); + if (!pinnedTab) return []; + + const associatedTabIds = new Set(); + const affectedWindowIds = new Set(); + for (const tabId of new Set(pinnedTab.associations.values())) { + associatedTabIds.add(tabId); + // Make associated tabs normal again + const tab = this.tabs.get(tabId); + if (tab) { + tab.owner = { kind: "normal" }; + affectedWindowIds.add(tab.getWindow().id); + this.emitContentChange(tab.getWindow().id, tab.id); + } + } + + this.pinnedTabs.delete(pinnedTabId); + this.pinnedTabDb.delete(pinnedTabId); + pinnedTab.destroy(); + + this.emit("pinned-tab-changed"); + for (const windowId of affectedWindowIds) { + this.emitStructuralChange(windowId); + } + return [...associatedTabIds]; + } + + /** + * Click a pinned tab — activate or create its associated tab. + * Pinned tab nodes exist in all profile layouts. Clicking just sets the + * target layout's active node — the node can be active in multiple layouts + * simultaneously. For cross-window clicks, the tab's view moves to the + * target window (since a view can only render in one window). + */ + public async clickPinnedTab(pinnedTabId: string, window: BrowserWindow): Promise { + const pinnedTab = this.pinnedTabs.get(pinnedTabId); + if (!pinnedTab) return false; + + const spaceId = window.currentSpaceId; + if (!spaceId) return false; + + // Find the existing associated tab (any space) + const existingTab = this.findAssociatedTab(pinnedTab); + if (existingTab) { + const targetLayout = this.getOrCreateLayout(window.id, spaceId); + const node = targetLayout.getNodeForTab(existingTab.id); + + if (node) { + // Node is already in this layout (propagated). Just activate it here. + // For cross-window: move only the tab's view (NOT the layout node). + // Pinned nodes stay in all layouts — we don't migrate them. + if (existingTab.getWindow().id !== window.id) { + const oldWindow = existingTab.getWindow(); + // Capture placeholder for old window before moving the view away + await sendPlaceholderForTab(existingTab, oldWindow); + // Re-check after async: tab or window may have been destroyed + if (existingTab.isDestroyed || window.destroyed) return true; + existingTab.setWindow(window); + node.setActiveLayout(targetLayout); + } + + // Update association to track which space last activated it + pinnedTab.associate(spaceId, existingTab.id); + + this.reorderPinnedTabsInSpace(window.id, spaceId); + targetLayout.setActiveNode(node); + targetLayout.setFocusedTab(existingTab); + this.activateTab(existingTab); + return true; + } + + // Node not in target layout (shouldn't happen if propagation worked, but fallback) + this.activateTab(existingTab); + return true; + } + + // No existing tab — create one + const tab = await this.createTab(window.id, pinnedTab.profileId, spaceId, undefined, { + url: pinnedTab.defaultUrl, + owner: { kind: "pinned", pinnedTabId: pinnedTab.uniqueId } + }); + + // Re-check after async: window or tab may have been destroyed during profile load. + if (tab.isDestroyed || window.destroyed) { + if (!tab.isDestroyed) tab.destroy(); + return true; + } + + pinnedTab.associate(spaceId, tab.id); + + // Propagate pinned tab node to all layouts in the same profile + const layout = this.getLayout(window.id, spaceId); + if (layout) { + const node = layout.getNodeForTab(tab.id); + if (node) { + pinnedTab.layoutNode = node; + this.propagatePinnedTabNode(node, pinnedTab.profileId); + } + } + + this.reorderPinnedTabsInSpace(window.id, spaceId); + this.activateTab(tab); + return true; + } + + /** + * Find the live associated tab for a pinned tab (across all spaces). + */ + private findAssociatedTab(pinnedTab: PinnedTab): Tab | null { + for (const tabId of pinnedTab.associations.values()) { + const tab = this.tabs.get(tabId); + if (tab && !tab.isDestroyed) return tab; + } + return null; + } + + /** + * Register a pinned tab's layout node in all layouts belonging to the same profile. + * The node uses activeLayout to determine which layout shows real content vs placeholder. + */ + public propagatePinnedTabNode(node: TabLayoutNode, profileId: string): void { + for (const layout of this.layouts.values()) { + if (layout.getNode(node.id)) continue; + // Check if this layout's space belongs to the same profile + const spaceData = spacesController.getFromCache(layout.spaceId); + if (spaceData && spaceData.profileId === profileId) { + layout.addExistingNode(node); + } + } + } + + /** + * Double-click a pinned tab — navigate back to default URL. + */ + public async doubleClickPinnedTab(pinnedTabId: string, window: BrowserWindow): Promise { + const pinnedTab = this.pinnedTabs.get(pinnedTabId); + if (!pinnedTab) return false; + + const spaceId = window.currentSpaceId; + if (!spaceId) return false; + + // Find existing tab across all spaces + const existingTab = this.findAssociatedTab(pinnedTab); + if (existingTab) { + // Navigate back to default URL + if (existingTab.url !== pinnedTab.defaultUrl) { + existingTab.loadURL(pinnedTab.defaultUrl); + } + + const targetLayout = this.getOrCreateLayout(window.id, spaceId); + const node = targetLayout.getNodeForTab(existingTab.id); + + if (node) { + // For cross-window: move only the view (not the node) + if (existingTab.getWindow().id !== window.id) { + const oldWindow = existingTab.getWindow(); + await sendPlaceholderForTab(existingTab, oldWindow); + // Re-check after async: tab or window may have been destroyed + if (existingTab.isDestroyed || window.destroyed) return true; + existingTab.setWindow(window); + node.setActiveLayout(targetLayout); + } + + pinnedTab.associate(spaceId, existingTab.id); + targetLayout.setActiveNode(node); + targetLayout.setFocusedTab(existingTab); + } + + this.activateTab(existingTab); + return true; + } + + // No associated tab — treat as single click + return this.clickPinnedTab(pinnedTabId, window); + } + + /** + * Unpin a tab back to the tab list. + */ + public async unpinToTabList(pinnedTabId: string, window?: BrowserWindow, position?: number): Promise { + const pinnedTab = this.pinnedTabs.get(pinnedTabId); + if (!pinnedTab) return false; + + // Collect affected window IDs before destroying (which clears associations) + const affectedWindowIds = new Set(); + let convertedTab: Tab | null = null; + + const targetWindow = window && !window.destroyed ? window : browserWindowsController.getFocusedWindow(); + const currentSpaceId = targetWindow?.currentSpaceId; + const currentSpaceTabId = currentSpaceId ? pinnedTab.getAssociatedTabId(currentSpaceId) : null; + if (currentSpaceTabId !== null) { + convertedTab = this.tabs.get(currentSpaceTabId) ?? null; + } + + if (!convertedTab && targetWindow && currentSpaceId) { + const space = await spacesController.get(currentSpaceId); + if (space?.profileId === pinnedTab.profileId) { + convertedTab = await this.createTab(targetWindow.id, pinnedTab.profileId, currentSpaceId, undefined, { + url: pinnedTab.defaultUrl, + owner: { kind: "normal" }, + position, + makeActive: true + }); + affectedWindowIds.add(convertedTab.getWindow().id); + if (position !== undefined) { + this.positioner.normalizePositions( + this.getTabsInWindowSpace(convertedTab.getWindow().id, convertedTab.spaceId) + ); + } + } + } + + if (!convertedTab) { + convertedTab = this.findAssociatedTab(pinnedTab); + } + if (!convertedTab) return false; + + for (const tabId of new Set(pinnedTab.associations.values())) { + const tab = this.tabs.get(tabId); + if (tab) { + tab.owner = { kind: "normal" }; + if (tab === convertedTab && position !== undefined) { + tab.updateStateProperty("position", position); + this.positioner.normalizePositions(this.getTabsInWindowSpace(tab.getWindow().id, tab.spaceId)); + } + affectedWindowIds.add(tab.getWindow().id); + this.emitContentChange(tab.getWindow().id, tab.id); + } + } + + if (convertedTab && !pinnedTab.hasAssociation(convertedTab.id)) { + affectedWindowIds.add(convertedTab.getWindow().id); + } + + this.pinnedTabs.delete(pinnedTabId); + this.pinnedTabDb.delete(pinnedTabId); + pinnedTab.destroy(); + + this.emit("pinned-tab-changed"); + + // Emit structural change for all affected windows + for (const windowId of affectedWindowIds) { + this.emitStructuralChange(windowId); + } + + return true; + } + + /** + * Reorder a pinned tab. + */ + public reorderPinnedTab(pinnedTabId: string, newPosition: number): void { + const pinnedTab = this.pinnedTabs.get(pinnedTabId); + if (!pinnedTab) return; + + pinnedTab.updatePosition(newPosition); + this.normalizePinnedTabPositions(pinnedTab.profileId); + this.emit("pinned-tab-changed"); + } + + /** + * Get pinned tabs for a profile, sorted by position. + */ + public getPinnedTabsForProfile(profileId: string): PinnedTab[] { + const result: PinnedTab[] = []; + for (const pt of this.pinnedTabs.values()) { + if (pt.profileId === profileId) result.push(pt); + } + return result.sort((a, b) => a.position - b.position); + } + + /** + * Get all pinned tabs grouped by profile. + */ + public getAllPinnedTabsByProfile(): Record { + const result: Record = {}; + for (const pt of this.pinnedTabs.values()) { + if (!result[pt.profileId]) result[pt.profileId] = []; + result[pt.profileId].push(pt); + } + for (const profileId of Object.keys(result)) { + result[profileId].sort((a, b) => a.position - b.position); + } + return result; + } + + /** + * Get a pinned tab by its associated tab ID (reverse lookup). + */ + public getPinnedTabByAssociatedTabId(tabId: number): PinnedTab | undefined { + for (const pt of this.pinnedTabs.values()) { + if (pt.hasAssociation(tabId)) return pt; + } + return undefined; + } + + // --- Tab Movement --- + + /** + * Move a tab to a new position. + */ + public moveTab(tabId: number, newPosition: number): void { + const tab = this.tabs.get(tabId); + if (!tab) return; + + const windowId = tab.getWindow().id; + const spaceId = tab.spaceId; + const layout = this.getLayout(windowId, spaceId); + const needsLayoutRefresh = layout?.getNodes().some((node) => node.mode !== "single") ?? false; + + const positionChanged = tab.updateStateProperty("position", newPosition); + this.positioner.normalizePositions(this.getTabsInWindowSpace(windowId, spaceId)); + + if (positionChanged && needsLayoutRefresh) { + this.emitStructuralChange(windowId); + } + + // Notify extensions that indices shifted after reorder + this.notifyIndexChanges(windowId, tab.profileId); + } + + /** + * Normalize tab positions in a window-space (assigns sequential integers). + */ + public normalizePositions(windowId: number, spaceId: string): void { + this.positioner.normalizePositions(this.getTabsInWindowSpace(windowId, spaceId)); + } + + /** + * Move a tab to a different space. + */ + public moveTabToSpace(tabId: number, spaceId: string, newPosition?: number): void { + const tab = this.tabs.get(tabId); + if (!tab) return; + + const sourceSpaceId = tab.spaceId; + if (sourceSpaceId === spaceId) return; + + const windowId = tab.getWindow().id; + const sourceLayout = this.getLayout(windowId, sourceSpaceId); + const targetLayout = this.getOrCreateLayout(windowId, spaceId); + + // Hide the tab before moving (it's leaving the source space) + if (tab.visible) { + tab.visible = false; + tab.layer?.setVisible(false); + } + + // Remove from source layout + if (sourceLayout) { + const node = sourceLayout.getNodeForTab(tab.id); + if (node) { + // Destroy single node from source, or remove tab from multi-node. + // The "destroyed" event cascades cleanup to all member layouts. + if (node.mode === "single") { + sourceLayout.destroyNode(node.id); + } else { + node.removeTab(tab); + } + } + } + + // Update tab's space + tab.setSpace(spaceId); + + // Create a new node in the target layout + targetLayout.createSingleNode(tab); + + // Clear focused tab references to this tab in the source space across ALL layouts. + for (const layout of this.layouts.values()) { + if (layout.spaceId === sourceSpaceId && layout.getFocusedTab()?.id === tab.id) { + layout.removeFocusedTab(); + } + } + + if (newPosition !== undefined) { + tab.updateStateProperty("position", newPosition); + } + + // Normalize both spaces + this.positioner.normalizePositions(this.getTabsInWindowSpace(windowId, spaceId)); + this.positioner.normalizePositions(this.getTabsInWindowSpace(windowId, sourceSpaceId)); + + // Notify extensions that indices shifted (tab moved between spaces) + this.notifyIndexChanges(windowId, tab.profileId); + + // Notify renderer that source space changed (tab removed) + this.emitStructuralChange(windowId); + } + + /** + * Set muted state for a tab. + */ + public setTabMuted(tabId: number, muted: boolean): boolean { + const tab = this.tabs.get(tabId); + if (!tab) return false; + + tab.webContents?.setAudioMuted(muted); + tab.updateTabState(); + return true; + } + + // --- Layout Management --- + + private layoutKey(windowId: number, spaceId: string): string { + return `${windowId}-${spaceId}`; + } + + /** + * Get a layout for a specific window-space. + */ + public getLayout(windowId: number, spaceId: string): TabLayout | undefined { + return this.layouts.get(this.layoutKey(windowId, spaceId)); + } + + /** + * Get all layouts for a given window. + */ + public getLayoutsForWindow(windowId: number): TabLayout[] { + const result: TabLayout[] = []; + for (const layout of this.layouts.values()) { + if (layout.windowId === windowId) result.push(layout); + } + return result; + } + + /** + * Get the currently visible layout for a window (matching current space). + */ + public getVisibleLayout(windowId: number): TabLayout | undefined { + const window = browserWindowsController.getWindowById(windowId); + if (!window?.currentSpaceId) return undefined; + return this.getLayout(windowId, window.currentSpaceId); + } + + public getOrCreateLayout(windowId: number, spaceId: string): TabLayout { + const key = this.layoutKey(windowId, spaceId); + let layout = this.layouts.get(key); + if (!layout) { + layout = new TabLayout(windowId, spaceId, this.positioner); + this.layouts.set(key, layout); + + // Set visibility based on whether this space is currently active + const window = browserWindowsController.getWindowById(windowId); + if (window && window.currentSpaceId === spaceId) { + layout.setVisible(true); + } + + // Forward events + const newLayout = layout; + newLayout.on("active-changed", (wId, sId) => { + if (newLayout.visible) { + this.updateTabVisibility(wId, sId); + } + this.emit("active-changed", wId, sId); + }); + newLayout.on("focused-tab-changed", (wId, sId) => { + this.emit("focused-tab-changed", wId, sId); + }); + + // Exit tab fullscreen when OS window exits fullscreen (register once per window) + this.ensureWindowFullscreenListener(windowId); + + // Register any existing pinned tab nodes from this profile into the new layout. + const spaceData = spacesController.getFromCache(spaceId); + if (spaceData) { + for (const pinnedTab of this.pinnedTabs.values()) { + if (pinnedTab.profileId !== spaceData.profileId) continue; + const node = pinnedTab.layoutNode; + if (node && !node.isDestroyed && !layout.getNode(node.id)) { + layout.addExistingNode(node); + } + } + } + } + return layout; + } + + private _windowFullscreenListeners: Set = new Set(); + + private ensureWindowFullscreenListener(windowId: number): void { + if (this._windowFullscreenListeners.has(windowId)) return; + this._windowFullscreenListeners.add(windowId); + + const window = browserWindowsController.getWindowById(windowId); + if (window) { + window.on("leave-full-screen", () => { + const currentSpaceId = window.currentSpaceId; + if (!currentSpaceId) return; + for (const tab of this.getTabsInWindowSpace(windowId, currentSpaceId)) { + if (tab.fullScreen) { + tab.setFullScreen(false); + } + } + }); + } + } + + public removeLayout(windowId: number, spaceId: string): void { + const key = this.layoutKey(windowId, spaceId); + const layout = this.layouts.get(key); + if (layout) { + layout.destroy(); + this.layouts.delete(key); + } + } + + /** + * Remove all layouts for a window (on window close). + */ + public removeAllLayoutsForWindow(windowId: number): void { + for (const [key, layout] of this.layouts) { + if (layout.windowId === windowId) { + layout.destroy(); + this.layouts.delete(key); + } + } + this._windowFullscreenListeners.delete(windowId); + } + + // --- Tab Visibility --- + + /** + * Update tab visibility for a given window+space. + * Tabs in the active node are shown; all others in that space are hidden. + */ + private updateTabVisibility(windowId: number, spaceId: string): void { + const layout = this.getLayout(windowId, spaceId); + if (!layout) return; + if (!layout.visible) return; + + const activeNode = layout.getActiveNode(); + + // Collect all tabs that belong to this layout's scope: + // - Normal tabs in this window+space + // - Tabs in the active node (includes pinned tabs whose spaceId may differ) + const tabsInSpace = this.getTabsInWindowSpace(windowId, spaceId); + const allRelevantTabs = new Set(tabsInSpace); + if (activeNode) { + for (const tab of activeNode.tabs) { + allRelevantTabs.add(tab); + } + } + + for (const tab of allRelevantTabs) { + const shouldBeVisible = activeNode !== null && activeNode.hasTab(tab.id); + if (tab.visible !== shouldBeVisible) { + const wasVisible = tab.visible; + + // When a tab is being hidden, record the time so archive/sleep timers + // measure from when the user actually stopped viewing it. + if (!shouldBeVisible) { + tab.lastActiveAt = Math.floor(Date.now() / 1000); + if (tab.fullScreen) { + tab.setFullScreen(false); + } + } + tab.visible = shouldBeVisible; + tab.layer?.setVisible(shouldBeVisible); + + // PiP transitions on visibility change + if (wasVisible && !shouldBeVisible && tab.layer) { + // Tab became hidden — auto-enter PiP if playing video + const isStillVisibleElsewhere = this.isTabVisibleInAnotherWindow(tab); + if (this._pipCount === 0 && !isStillVisibleElsewhere) { + tab.enterPictureInPicture(); + } + } else if (!wasVisible && shouldBeVisible && tab.isPictureInPicture) { + // Tab became visible — exit PiP + tab.exitPictureInPicture(); + } + } + } + } + + /** + * Returns true when the tab is visible (active) in a different browser window. + * Used to prevent auto-PiP for tabs that are still on-screen elsewhere (STAW). + */ + private isTabVisibleInAnotherWindow(tab: Tab): boolean { + const tabWindowId = tab.getWindow().id; + for (const layout of this.layouts.values()) { + if (layout.windowId === tabWindowId) continue; + if (!layout.visible) continue; + const window = browserWindowsController.getWindowById(layout.windowId); + if (!window || window.destroyed || window.browserWindowType !== "normal") continue; + const activeNode = layout.getActiveNode(); + if (activeNode && activeNode.hasTab(tab.id)) return true; + } + return false; + } + + // --- Window Space Management --- + + public setCurrentWindowSpace(windowId: number, spaceId: string, oldSpaceId?: string | null): void { + const window = browserWindowsController.getWindowById(windowId); + if (!window) return; + + // Toggle layout visibility: hide old space layout, show new space layout + if (oldSpaceId && oldSpaceId !== spaceId) { + const oldLayout = this.getLayout(windowId, oldSpaceId); + if (oldLayout) { + oldLayout.setVisible(false); + // Hide all visible tabs in old layout. + // Include active node tabs (pinned tabs may have a different spaceId). + const tabsToHide = new Set(this.getTabsInWindowSpace(windowId, oldSpaceId)); + const oldActiveNode = oldLayout.getActiveNode(); + if (oldActiveNode) { + for (const tab of oldActiveNode.tabs) { + tabsToHide.add(tab); + } + } + for (const tab of tabsToHide) { + if (tab.visible) { + tab.lastActiveAt = Math.floor(Date.now() / 1000); + if (tab.fullScreen) { + tab.setFullScreen(false); + } + tab.visible = false; + tab.layer?.setVisible(false); + + // Auto-PiP for hidden tabs with playing video + if (tab.layer) { + if (this._pipCount === 0 && !this.isTabVisibleInAnotherWindow(tab)) { + tab.enterPictureInPicture(); + } + } + } + } + } + } + + // Pinned tabs are NOT auto-relocated on space switch. They only move + // when the user explicitly activates them (via clickPinnedTab). + + const layout = this.getLayout(windowId, spaceId); + + // If no active node is set yet (e.g. tabs were restored asleep), optionally + // activate the focused tab or the most recently active one. + if (FLAGS.ACTIVATE_TAB_ON_SPACE_SWITCH && layout && !layout.getActiveNode()) { + const focused = layout.getFocusedTab(); + if (focused && !focused.isDestroyed) { + this.activateTab(focused); + return; + } + // Fall back to the most recently active tab in this space + const tabsInSpace = this.getTabsInWindowSpace(windowId, spaceId); + if (tabsInSpace.length > 0) { + const sorted = tabsInSpace.sort((a, b) => b.lastActiveAt - a.lastActiveAt); + this.activateTab(sorted[0]); + return; + } + } + + // Mark new layout as visible + if (layout) { + layout.setVisible(true); + } + + this.updateTabVisibility(windowId, spaceId); + this.handlePageBoundsChanged(windowId); + this.emitStructuralChange(windowId); + } + + public handlePageBoundsChanged(windowId: number): void { + // Delegate bounds calculation to each layout (which delegates to its active node) + for (const layout of this.getLayoutsForWindow(windowId)) { + layout.applyBounds(); + } + } + + // --- Event Helpers --- + + public emitStructuralChange(windowId: number): void { + if (quitController.isQuitting) return; + if (this._suppressEmissions > 0) { + this._deferredStructural.add(windowId); + return; + } + this.emit("structural-change", windowId); + } + + public emitContentChange(windowId: number, tabId: number): void { + if (quitController.isQuitting) return; + if (this._suppressEmissions > 0) { + // Structural change will include content anyway + this._deferredStructural.add(windowId); + return; + } + this.emit("content-change", windowId, tabId); + } + + /** + * Notify all tabs in a window+profile that their index may have changed. + * Called after structural changes (tab create/destroy/move/reorder). + */ + private notifyIndexChanges(windowId: number, profileId: string): void { + for (const tab of this.getTabsInWindowProfile(windowId, profileId)) { + tab.notifyExtensionsOfChanges(); + } + } + + /** + * Suppress emissions during batch operations. Call endBatch() when done + * to flush a single structural change for each affected window. + */ + public beginBatch(): void { + this._suppressEmissions++; + } + + public endBatch(): void { + this._suppressEmissions--; + if (this._suppressEmissions <= 0) { + this._suppressEmissions = 0; + for (const windowId of this._deferredStructural) { + this.emit("structural-change", windowId); + } + this._deferredStructural.clear(); + } + } + + // --- Private Methods --- + + private wireTabEvents(tab: Tab): void { + tab.on("updated", (props) => { + if (quitController.isQuitting) return; + // Track PiP counter for O(1) "any tab in PiP" checks + if (props.includes("isPictureInPicture")) { + this._pipCount += tab.isPictureInPicture ? 1 : -1; + } + // Update webContents index when tab wakes up (new webContents created). + // Old keys are GC'd automatically since webContentsIndex is a WeakMap. + if (props.includes("asleep") && !tab.asleep && tab.webContents) { + this.webContentsIndex.set(tab.webContents, tab); + } + // Notify extension system of tab state changes (title, url, muted, etc.) + tab.notifyExtensionsOfChanges(); + this.emitContentChange(tab.getWindow().id, tab.id); + }); + + tab.on("content-changed", () => { + if (quitController.isQuitting) return; + this.emitContentChange(tab.getWindow().id, tab.id); + }); + + tab.on("space-changed", (oldSpaceId) => { + if (quitController.isQuitting) return; + // Update space index + this.removeFromIndex(this.spaceIndex, oldSpaceId, tab); + this.addToIndex(this.spaceIndex, tab.spaceId, tab); + + // Content change invalidates the serialization cache (spaceId changed) + this.emitContentChange(tab.getWindow().id, tab.id); + this.emitStructuralChange(tab.getWindow().id); + }); + + tab.on("window-changed", (oldWindowId) => { + if (quitController.isQuitting) return; + // Update window index + this.removeFromIndex(this.windowIndex, oldWindowId, tab); + this.addToIndex(this.windowIndex, tab.getWindow().id, tab); + + this.emitStructuralChange(tab.getWindow().id); + if (oldWindowId !== tab.getWindow().id) { + this.emitStructuralChange(oldWindowId); + // Index shifted in both old and new window + this.notifyIndexChanges(oldWindowId, tab.profileId); + } + this.notifyIndexChanges(tab.getWindow().id, tab.profileId); + // Re-serialize so persistence picks up the new windowGroupId + this.emitContentChange(tab.getWindow().id, tab.id); + }); + + tab.on("focused", () => { + const window = tab.getWindow(); + const spaceId = window.currentSpaceId ?? tab.spaceId; + const currentLayout = this.getLayout(window.id, spaceId); + if (currentLayout && this.isTabActive(tab)) { + currentLayout.setFocusedTab(tab); + } + }); + + tab.on("fullscreen-changed", () => { + if (quitController.isQuitting) return; + this.handlePageBoundsChanged(tab.getWindow().id); + }); + + tab.on("target-url-changed", (url) => { + if (quitController.isQuitting) return; + const window = tab.getWindow(); + if (window.destroyed) return; + window.sendMessageToCoreWebContents("tab-service:on-target-url", { + tabId: tab.id, + windowId: window.id, + url + }); + }); + + tab.on("new-tab-requested", (url, disposition, constructorOptions, handlerDetails, options) => { + this.handleNewTabRequested(tab, url, disposition, constructorOptions, handlerDetails, options); + }); + + tab.on("destroyed", () => { + // Always clean up indexes + this.removeFromIndex(this.windowIndex, tab.getWindow().id, tab); + this.removeFromIndex(this.spaceIndex, tab.spaceId, tab); + + // Decrement PiP counter if the tab was in PiP when destroyed + if (tab.isPictureInPicture) { + this._pipCount--; + } + + if (quitController.isQuitting) { + this.tabs.delete(tab.id); + return; + } + + const windowId = tab.getWindow().id; + const position = tab.position; + + // Find the layout containing this tab. Prefer window's current space + // (handles pinned tabs whose spaceId differs from active space). + const win = browserWindowsController.getWindowById(windowId); + const currentSpaceId = win?.currentSpaceId; + let currentLayout = currentSpaceId ? this.getLayout(windowId, currentSpaceId) : undefined; + if (!currentLayout?.getNodeForTab(tab.id)) { + currentLayout = this.getLayout(windowId, tab.spaceId); + } + + // Determine if tab was active. The once("destroyed") listener from + // TabLayoutNode.addTab fires before this handler (registered earlier), + // so it may have already removed the tab → emptied the node → + // auto-destroyed the node → layout set activeNode = null. + // If activeNode is null, it means the active node was just destroyed + // (the only path that nulls activeNode during a tab destroy), so the + // tab was active. + let wasActive = false; + if (currentLayout) { + const activeNode = currentLayout.getActiveNode(); + if (activeNode) { + wasActive = activeNode.hasTab(tab.id); + } else { + // Active node was just destroyed — this tab was its last occupant + wasActive = true; + } + } + + // Store in recently closed (only normal tabs with URLs) + if (tab.owner.kind === "normal" && tab.url) { + this.recentlyClosed.add(this.serializeTabForPersistence(tab)); + } + + // Clean up pinned tab association and layout node reference + const pinnedTab = this.getPinnedTabByAssociatedTabId(tab.id); + if (pinnedTab) { + pinnedTab.dissociateByTabId(tab.id); + pinnedTab.layoutNode = null; + } + + // Remove from layout node (may already be removed by once listener) + if (currentLayout) { + const node = currentLayout.getNodeForTab(tab.id); + if (node) { + node.removeTab(tab); + } + } + + // Remove from tracking + this.tabs.delete(tab.id); + this.emit("tab-removed", tab); + + // Handle active tab selection + if (wasActive && currentLayout) { + currentLayout.removeActiveAndSelectNext(position); + } + + this.emitStructuralChange(windowId); + + // Notify extensions that indices shifted for remaining tabs in same profile + this.notifyIndexChanges(windowId, tab.profileId); + + // Auto-close empty popup windows + this.reconcilePopupWindow(windowId); + }); + } + + /** + * If a popup window has no tabs left, close it. Otherwise, activate + * the best remaining tab. + */ + private reconcilePopupWindow(windowId: number): void { + if (quitController.isQuitting) return; + const window = browserWindowsController.getWindowById(windowId); + if (!window || window.destroyed || window.browserWindowType !== "popup") return; + + const tabsInWindow = this.getTabsInWindow(windowId); + if (tabsInWindow.length === 0) { + setImmediate(() => { + const latestWindow = browserWindowsController.getWindowById(windowId); + if (!latestWindow || latestWindow.destroyed || latestWindow.browserWindowType !== "popup") return; + if (this.getTabsInWindow(windowId).length > 0) return; + latestWindow.close(); + }); + return; + } + + // If there's no active tab, activate the most recently active one + const currentSpaceId = window.currentSpaceId; + if (!currentSpaceId) return; + const layout = this.getLayout(windowId, currentSpaceId); + if (!layout) return; + const activeNode = layout.getActiveNode(); + if (activeNode) return; + + // Find the best tab to activate + const spaceTabs = tabsInWindow + .filter((t) => t.spaceId === currentSpaceId) + .sort((a, b) => b.lastActiveAt - a.lastActiveAt); + const bestTab = spaceTabs[0] ?? tabsInWindow.sort((a, b) => b.lastActiveAt - a.lastActiveAt)[0]; + if (bestTab) { + this.activateTab(bestTab); + } + } + + private handleNewTabRequested( + sourceTab: Tab, + url: string, + disposition: "new-window" | "foreground-tab" | "background-tab" | "default" | "other", + constructorOptions: Electron.WebContentsViewConstructorOptions | undefined, + handlerDetails: Electron.HandlerDetails | undefined, + options: { noLoadURL?: boolean } + ): void { + let targetWindow = sourceTab.getWindow(); + let windowId = targetWindow.id; + let targetSpaceId = targetWindow.currentSpaceId ?? sourceTab.spaceId; + + if (disposition === "new-window") { + const parsedFeatures: Record = {}; + if (handlerDetails?.features) { + for (const feature of handlerDetails.features.split(",")) { + const [key, value] = feature.trim().split("="); + if (key && value) { + parsedFeatures[key] = Number.isNaN(+value) ? value : +value; + } + } + } + + const popupWindow = browserWindowsController.instantCreate("popup", { + ...(parsedFeatures.width ? { width: +parsedFeatures.width } : {}), + ...(parsedFeatures.height ? { height: +parsedFeatures.height } : {}), + ...(parsedFeatures.left ? { x: +parsedFeatures.left } : {}), + ...(parsedFeatures.top ? { y: +parsedFeatures.top } : {}) + }); + popupWindow.setCurrentSpace(targetSpaceId); + targetWindow = popupWindow; + windowId = popupWindow.id; + } else { + targetSpaceId = targetWindow.currentSpaceId ?? sourceTab.spaceId; + } + + const insertPosition = disposition !== "new-window" ? sourceTab.position + 0.5 : undefined; + + const isBackground = disposition === "background-tab"; + const newTab = this.createTabInternal(windowId, sourceTab.profileId, targetSpaceId, undefined, { + url, + noLoadURL: options.noLoadURL, + webContentsViewOptions: constructorOptions, + position: insertPosition, + makeActive: !isBackground + }); + + if (insertPosition !== undefined) { + this.positioner.normalizePositions(this.getTabsInWindowSpace(targetWindow.id, targetSpaceId)); + } + + sourceTab._lastCreatedWebContents = newTab.webContents; + } + + private wirePinnedTabEvents(pinnedTab: PinnedTab): void { + pinnedTab.on("association-changed", () => { + this.emit("pinned-tab-changed"); + }); + pinnedTab.on("updated", () => { + this.pinnedTabDb.save(pinnedTab); + this.emit("pinned-tab-changed"); + }); + } + + private getMaxPinnedTabPosition(profileId: string): number { + let max = -1; + for (const pt of this.pinnedTabs.values()) { + if (pt.profileId === profileId && pt.position > max) { + max = pt.position; + } + } + return max; + } + + private normalizePinnedTabPositions(profileId: string): void { + const sorted = this.getPinnedTabsForProfile(profileId); + for (let i = 0; i < sorted.length; i++) { + if (sorted[i].position !== i) { + sorted[i].updatePosition(i); + } + } + } + + /** + * Reorder pinned-tab-owned nodes in a layout so their positions match the + * pinned tab grid order. Uses the layout's nodes directly (not + * getTabsInWindowSpace) because pinned tab views may be in a different + * window while their nodes remain propagated in this layout. + */ + private reorderPinnedTabsInSpace(windowId: number, spaceId: string): void { + const layout = this.getLayout(windowId, spaceId); + if (!layout) return; + + const pinnedNodes: { node: TabLayoutNode; pinnedPosition: number }[] = []; + const normalNodes: TabLayoutNode[] = []; + + for (const node of layout.getNodes()) { + const frontTab = node.frontTab; + if (!frontTab) continue; + if (frontTab.owner.kind === "pinned") { + const pinnedTab = this.pinnedTabs.get(frontTab.owner.pinnedTabId); + pinnedNodes.push({ node, pinnedPosition: pinnedTab?.position ?? 0 }); + } else { + normalNodes.push(node); + } + } + + if (pinnedNodes.length === 0) return; + + // Sort pinned nodes by their pinned tab's grid position + pinnedNodes.sort((a, b) => a.pinnedPosition - b.pinnedPosition); + + // Assign positions: pinned nodes first (in order), then normal nodes + let pos = 0; + for (const { node } of pinnedNodes) { + const tab = node.frontTab!; + if (tab.position !== pos) { + tab.updateStateProperty("position", pos); + } + pos++; + } + + normalNodes.sort((a, b) => a.position - b.position); + for (const node of normalNodes) { + const tab = node.frontTab!; + if (tab.position !== pos) { + tab.updateStateProperty("position", pos); + } + pos++; + } + } + + // --- Picture in Picture --- + + public disablePictureInPicture(tabId: number, goBackToTab: boolean): boolean { + const tab = this.tabs.get(tabId); + if (!tab || !tab.isPictureInPicture) return false; + + tab.updateStateProperty("isPictureInPicture", false); + + if (goBackToTab) { + const win = tab.getWindow(); + setWindowSpace(win, tab.spaceId); + win.browserWindow.focus(); + this.activateTab(tab); + } + + return true; + } + + // --- Batch Tab Move --- + + public batchMoveTabs(tabIds: number[], spaceId: string, window: BrowserWindow, newPositionStart?: number): boolean { + const affectedSourceSpaces = new Set(); + + for (let i = 0; i < tabIds.length; i++) { + const tab = this.tabs.get(tabIds[i]); + if (!tab) continue; + + const sourceSpaceId = tab.spaceId; + const sourceWindowId = tab.getWindow().id; + const leavingSourceLayout = sourceSpaceId !== spaceId || sourceWindowId !== window.id; + + // Hide only if actually leaving the current window-space + if (leavingSourceLayout && tab.visible) { + tab.visible = false; + tab.layer?.setVisible(false); + } + + // Remove from source layout node + if (leavingSourceLayout) { + const sourceLayout = this.getLayout(sourceWindowId, sourceSpaceId); + if (sourceLayout) { + const node = sourceLayout.getNodeForTab(tab.id); + if (node && node.mode === "single") { + sourceLayout.destroyNode(node.id); + } else if (node) { + node.removeTab(tab); + } + } + affectedSourceSpaces.add(`${sourceWindowId}-${sourceSpaceId}`); + } + + tab.setSpace(spaceId); + tab.setWindow(window); + + // Create node in target layout + const targetLayout = this.getOrCreateLayout(window.id, spaceId); + if (!targetLayout.getNodeForTab(tab.id)) { + targetLayout.createSingleNode(tab); + } + + if (newPositionStart !== undefined) { + tab.updateStateProperty("position", newPositionStart + i); + } + } + + this.positioner.normalizePositions(this.getTabsInWindowSpace(window.id, spaceId)); + + // Emit structural changes for affected source windows + for (const key of affectedSourceSpaces) { + const [windowIdStr] = key.split("-"); + const windowId = parseInt(windowIdStr, 10); + this.emitStructuralChange(windowId); + } + this.emitStructuralChange(window.id); + + // Notify extensions of index changes for all moved tabs' profiles + const profileIds = new Set(); + for (const tabId of tabIds) { + const tab = this.tabs.get(tabId); + if (tab) profileIds.add(tab.profileId); + } + for (const profileId of profileIds) { + this.notifyIndexChanges(window.id, profileId); + } + + return true; + } + + // --- Recently Closed --- + + public getRecentlyClosed(): RecentlyClosedTabData[] { + return this.recentlyClosed.getAll(); + } + + public async restoreRecentlyClosed(uniqueId: string, window: BrowserWindow): Promise { + const result = this.recentlyClosed.restore(uniqueId); + if (!result) return false; + + const { tabData } = result; + const space = await spacesController.get(tabData.spaceId); + if (!space) return false; + + const tab = await this.createTab(window.id, space.profileId, tabData.spaceId, undefined, { + uniqueId: tabData.uniqueId, + createdAt: tabData.createdAt, + lastActiveAt: tabData.lastActiveAt, + position: tabData.position, + title: tabData.title, + faviconURL: tabData.faviconURL ?? undefined, + navHistory: tabData.navHistory, + navHistoryIndex: tabData.navHistoryIndex + }); + + this.activateTab(tab); + return true; + } + + public clearRecentlyClosed(): void { + this.recentlyClosed.clear(); + } + + // --- Context Menus --- + + public showContextMenu(tabId: number, window: BrowserWindow): void { + void showTabContextMenu(this, tabId, window); + } + + public showPinnedTabContextMenu(pinnedTabId: string, window: BrowserWindow): void { + void showPinnedTabContextMenu(this, pinnedTabId, window); + } + + // --- Serialization --- + + private serializeTabForPersistence(tab: Tab): PersistedTabData { + const navHistory: NavigationEntry[] = []; + let navHistoryIndex = 0; + + if (tab.webContents && !tab.webContents.isDestroyed()) { + const history = tab.webContents.navigationHistory; + const count = history.length(); + for (let i = 0; i < count; i++) { + const entry = history.getEntryAtIndex(i); + navHistory.push({ title: entry.title || "", url: entry.url, pageState: entry.pageState }); + } + navHistoryIndex = history.getActiveIndex(); + } else if (tab.navHistory.length > 0) { + navHistory.push(...tab.navHistory); + navHistoryIndex = tab.navHistoryIndex; + } else if (tab.url) { + navHistory.push({ title: tab.title, url: tab.url }); + navHistoryIndex = 0; + } + + return { + schemaVersion: TAB_SERVICE_SCHEMA_VERSION, + uniqueId: tab.uniqueId, + createdAt: tab.createdAt, + lastActiveAt: tab.lastActiveAt, + position: tab.position, + profileId: tab.profileId, + spaceId: tab.spaceId, + windowGroupId: `w-${tab.getWindow().id}`, + title: tab.title, + url: tab.url, + faviconURL: tab.faviconURL, + muted: tab.muted, + navHistory, + navHistoryIndex, + owner: tab.owner + }; + } + + // --- Index Helpers --- + + private addToIndex(index: Map>, key: K, tab: Tab): void { + let set = index.get(key); + if (!set) { + set = new Set(); + index.set(key, set); + } + set.add(tab); + } + + private removeFromIndex(index: Map>, key: K, tab: Tab): void { + const set = index.get(key); + if (set) { + set.delete(tab); + if (set.size === 0) index.delete(key); + } + } +} diff --git a/src/main/services/tab-service/tab-sync.ts b/src/main/services/tab-service/tab-sync.ts new file mode 100644 index 000000000..200f8ad8e --- /dev/null +++ b/src/main/services/tab-service/tab-sync.ts @@ -0,0 +1,515 @@ +/** + * Tab Sync — shared tab state across windows. + * + * When enabled (via the "syncTabsAcrossWindows" setting), every window sees + * the same tabs. When a window gains focus, the active tab's view is moved + * there. A screenshot placeholder is left in the old window. + * + * Pinned tabs ALWAYS sync across windows regardless of the setting. + * + * Disabled by default (each window has independent tabs). + */ + +import { getSettingValueById } from "@/saving/settings"; +import { windowsController } from "@/controllers/windows-controller"; +import { browserWindowsController } from "@/controllers/windows-controller/interfaces/browser"; +import type { BrowserWindow } from "@/controllers/windows-controller/types"; +import { spacesController } from "@/controllers/spaces-controller"; +import { + storeSnapshot, + removeSnapshot +} from "@/controllers/sessions-controller/protocols/_protocols/flow-internal/tab-snapshot"; +import type { TabPlaceholderUpdate } from "~/types/tab-service"; +import { Tab } from "./core/tab"; +import { tabService } from "./index"; + +// --- Screenshot Placeholders (served via flow-internal://tab-snapshot) --- + +const PLACEHOLDER_RELEASE_DELAY_MS = 180; + +type WindowPlaceholderState = { + snapshotId: string; + tabId: number; + generation: number; + spaceId: string; +}; + +const windowPlaceholderState: Map = new Map(); +const windowPlaceholderGeneration: Map = new Map(); + +function nextPlaceholderGeneration(windowId: number): number { + const generation = (windowPlaceholderGeneration.get(windowId) ?? 0) + 1; + windowPlaceholderGeneration.set(windowId, generation); + return generation; +} + +function sendPlaceholderUpdate(targetWindow: BrowserWindow, update: TabPlaceholderUpdate): void { + if (targetWindow.destroyed) return; + targetWindow.sendMessageToCoreWebContents("tab-service:on-placeholder-changed", update); +} + +async function captureTabScreenshot(tab: Tab): Promise { + const wc = tab.webContents; + if (!wc || wc.isDestroyed()) return null; + + const view = tab.view; + if (!view) return null; + + const bounds = view.getBounds(); + if (bounds.width <= 0 || bounds.height <= 0) return null; + + try { + const image = await wc.capturePage({ x: 0, y: 0, width: bounds.width, height: bounds.height }); + return image.isEmpty() ? null : image; + } catch { + return null; + } +} + +function sendPlaceholderToRenderer( + targetWindow: BrowserWindow, + spaceId: string, + tabId: number, + image: Electron.NativeImage +): void { + if (targetWindow.destroyed) return; + + const previousPlaceholder = windowPlaceholderState.get(targetWindow.id); + if (previousPlaceholder) { + removeSnapshot(previousPlaceholder.snapshotId); + } + + const generation = nextPlaceholderGeneration(targetWindow.id); + const snapshotId = storeSnapshot(image); + windowPlaceholderState.set(targetWindow.id, { snapshotId, tabId, generation, spaceId }); + sendPlaceholderUpdate(targetWindow, { snapshotId, generation, spaceId }); +} + +function clearPlaceholderInRenderer(windowId: number): void { + const generation = nextPlaceholderGeneration(windowId); + const placeholderState = windowPlaceholderState.get(windowId); + if (placeholderState) { + windowPlaceholderState.delete(windowId); + setTimeout(() => { + removeSnapshot(placeholderState.snapshotId); + }, PLACEHOLDER_RELEASE_DELAY_MS); + } + + const win = browserWindowsController.getWindowById(windowId); + if (!win) return; + + sendPlaceholderUpdate(win, { snapshotId: null, generation, spaceId: win.currentSpaceId }); +} + +export function clearPlaceholdersForTab(tabId: number): void { + for (const [windowId, placeholderState] of windowPlaceholderState.entries()) { + if (placeholderState.tabId !== tabId) continue; + clearPlaceholderInRenderer(windowId); + } +} + +/** + * Capture a screenshot of a tab and send it as a placeholder to the given window. + * Used when a pinned tab's view moves to another window — the old window shows + * a placeholder thumbnail instead of real content. + */ +export async function sendPlaceholderForTab(tab: Tab, targetWindow: BrowserWindow): Promise { + const screenshot = await captureTabScreenshot(tab); + if (screenshot && !targetWindow.destroyed) { + sendPlaceholderToRenderer(targetWindow, targetWindow.currentSpaceId ?? tab.spaceId, tab.id, screenshot); + } +} + +function reconcilePlaceholderForWindow(windowId: number): void { + const window = browserWindowsController.getWindowById(windowId); + if (!window || window.destroyed || window.browserWindowType !== "normal") return; + + const spaceId = window.currentSpaceId; + if (!spaceId) { + clearPlaceholderInRenderer(windowId); + return; + } + + const focusedTab = tabService.getFocusedTab(windowId, spaceId); + if (!focusedTab) { + clearPlaceholderInRenderer(windowId); + return; + } + + if (isSyncExcludedTab(focusedTab)) { + clearPlaceholderInRenderer(windowId); + return; + } + + // If the focused tab moved to a different space, the placeholder is stale. + // Exception: pinned tabs span all spaces — their spaceId is just the creation space. + if (focusedTab.spaceId !== spaceId && focusedTab.owner.kind !== "pinned") { + clearPlaceholderInRenderer(windowId); + return; + } + + // If the active tab is physically in this window, clear the placeholder + if (focusedTab.getWindow().id === windowId) { + clearPlaceholderInRenderer(windowId); + } +} + +// --- Core Helpers --- + +export function isTabSyncEnabled(): boolean { + return getSettingValueById("syncTabsAcrossWindows") === true; +} + +function isInternalProfileTab(tab: Tab): boolean { + return tab.loadedProfile.profileData.internal === true; +} + +function isPopupWindowTab(tab: Tab): boolean { + return tab.getWindow().browserWindowType === "popup"; +} + +export function isSyncExcludedTab(tab: Tab): boolean { + return isInternalProfileTab(tab) || isPopupWindowTab(tab); +} + +/** + * Whether a tab participates in cross-window sync. + * Pinned-tab-owned tabs always sync; others sync when the global setting is + * enabled and the tab isn't excluded (internal profile or popup window). + */ +export function isTabSynced(tab: Tab): boolean { + if (isSyncExcludedTab(tab)) return false; + return tab.owner.kind === "pinned" || isTabSyncEnabled(); +} + +function shouldSyncSharedActiveTab(window: BrowserWindow, spaceId: string): boolean { + if (isTabSyncEnabled()) return true; + const focusedTab = tabService.getFocusedTab(window.id, spaceId); + return !!focusedTab && isTabSynced(focusedTab); +} + +// --- Tab Moving --- + +function prepareTabForWindowTransfer(tab: Tab): void { + tab.visible = false; + if (tab.layer) { + tab.layer.setVisible(false); + } +} + +async function moveTabToWindowIfNeeded(tab: Tab, window: BrowserWindow, isStale?: () => boolean): Promise { + if (tab.isDestroyed || window.destroyed) return; + if (tab.getWindow().id !== window.id) { + const oldWindow = tab.getWindow(); + if (oldWindow.destroyed) return; + + // Capture screenshot BEFORE the move so the old window gets a placeholder. + // Use a short timeout to avoid blocking on unresponsive renderers. + const screenshot = await captureTabScreenshot(tab); + + // After async capture, re-check validity + if (isStale?.()) return; + if (tab.isDestroyed || window.destroyed || oldWindow.destroyed) return; + if (tab.getWindow().id === window.id) return; // already moved by another path + + // Send placeholder to old window before moving + if (screenshot) { + sendPlaceholderToRenderer(oldWindow, oldWindow.currentSpaceId ?? tab.spaceId, tab.id, screenshot); + } + + // Ensure the node exists in the target layout before moving the view. + // With multi-layout membership, nodes stay in both layouts (source shows + // placeholder, target shows real content via activeLayout). + tabService.ensureNodeInLayout(tab, window.id); + + prepareTabForWindowTransfer(tab); + tab.setWindow(window); + } +} + +async function moveActiveTabToWindow(window: BrowserWindow, isStale?: () => boolean): Promise { + const spaceId = window.currentSpaceId; + if (!spaceId) return; + + const focusedTab = tabService.getFocusedTab(window.id, spaceId); + if (!focusedTab) return; + + clearPlaceholderInRenderer(window.id); + + if (isSyncExcludedTab(focusedTab)) return; + + // Look up the node using the window's current space layout (not tab.spaceId, + // which may differ for pinned tabs whose spaceId is just the creation space). + const layout = tabService.getLayout(window.id, spaceId); + if (!layout) return; + + const node = layout.getNodeForTab(focusedTab.id); + if (node) { + if (isStale?.()) return; + for (const tab of node.tabs) { + if (!isSyncExcludedTab(tab)) { + await moveTabToWindowIfNeeded(tab, window, isStale); + } + } + } else { + await moveTabToWindowIfNeeded(focusedTab, window, isStale); + } +} + +export async function moveTabOrGroupToWindow(tab: Tab, window: BrowserWindow): Promise { + clearPlaceholderInRenderer(window.id); + + // Look up the node from the tab's current window. For pinned tabs, try the + // current space layout first (where the node is active), then fall back to tab.spaceId. + const sourceWindow = tab.getWindow(); + const sourceSpaceId = sourceWindow.currentSpaceId ?? tab.spaceId; + const layout = + tabService.getLayout(sourceWindow.id, sourceSpaceId) ?? tabService.getLayout(sourceWindow.id, tab.spaceId); + if (layout) { + const node = layout.getNodeForTab(tab.id); + if (node) { + for (const nodeTab of node.tabs) { + await moveTabToWindowIfNeeded(nodeTab, window); + } + return; + } + } + + await moveTabToWindowIfNeeded(tab, window); +} + +// --- Tab Relocation from Closing Window --- + +function findWindowWithProfile(windows: BrowserWindow[], profileId: string): BrowserWindow | null { + for (const win of windows) { + const spaceId = win.currentSpaceId; + if (!spaceId) continue; + const space = spacesController.getFromCache(spaceId); + if (space?.profileId === profileId) { + return win; + } + } + return null; +} + +export function relocateTabsFromClosingWindow(closingWindow: BrowserWindow, tabs: Tab[]): Tab[] | null { + const closingWindowId = closingWindow.id; + if (closingWindow.browserWindowType === "popup") return null; + + const survivingWindows = browserWindowsController + .getWindows() + .filter((w) => w.id !== closingWindowId && w.browserWindowType === "normal"); + if (survivingWindows.length === 0) return null; + + const defaultTargetWindow = survivingWindows[0]; + const relocatable = new Map(); + const unrelocatable: Tab[] = []; + + for (const tab of tabs) { + if (!isTabSynced(tab)) { + unrelocatable.push(tab); + continue; + } + + const isInternal = tab.loadedProfile.profileData.internal; + if (isInternal) { + const targetWindow = findWindowWithProfile(survivingWindows, tab.profileId); + if (targetWindow) { + const list = relocatable.get(targetWindow) ?? []; + list.push(tab); + relocatable.set(targetWindow, list); + } else { + unrelocatable.push(tab); + } + } else { + const list = relocatable.get(defaultTargetWindow) ?? []; + list.push(tab); + relocatable.set(defaultTargetWindow, list); + } + } + + if (relocatable.size === 0) return unrelocatable.length > 0 ? unrelocatable : null; + + for (const [targetWindow, windowTabs] of relocatable) { + for (const tab of windowTabs) { + tabService.ensureNodeInLayout(tab, targetWindow.id); + prepareTabForWindowTransfer(tab); + tab.setWindow(targetWindow); + } + } + + // Activate a tab in target windows so the UI shows something + for (const targetWindow of relocatable.keys()) { + const targetSpaceId = targetWindow.currentSpaceId; + if (targetSpaceId) { + const focusedTab = tabService.getFocusedTab(targetWindow.id, targetSpaceId); + if (focusedTab) { + tabService.activateTab(focusedTab); + } + } + } + + return unrelocatable; +} + +// --- Displaced Tab Relocation --- + +let _syncMoveQueue: Promise = Promise.resolve(); + +async function runTabSyncMutation(work: () => Promise): Promise { + const run = _syncMoveQueue.then(work, work); + _syncMoveQueue = run.then( + () => undefined, + () => undefined + ); + return run; +} + +// --- Initialization --- + +export function initTabSync(): void { + // Set the move-tab hook so TabService can call tab-sync's move logic + tabService.moveTabToWindowHook = (tab, window) => moveTabOrGroupToWindow(tab, window); + + // Move active tab view to focused window. + // The LayerManager defers reallocateFocus() when the window isn't focused, + // so we don't need debounce hacks here — background windows won't steal focus. + windowsController.on("window-focused", (id) => { + const window = browserWindowsController.getWindowById(id); + if (!window || window.destroyed || window.browserWindowType !== "normal") return; + + const spaceId = window.currentSpaceId; + if (!spaceId) return; + + if (!shouldSyncSharedActiveTab(window, spaceId)) return; + + const focusedTab = tabService.getFocusedTab(window.id, spaceId); + if (!focusedTab || focusedTab.isDestroyed) return; + if (isSyncExcludedTab(focusedTab)) return; + + // If the tab is already in this window, just activate + if (focusedTab.getWindow().id === window.id) { + clearPlaceholderInRenderer(window.id); + tabService.activateTab(focusedTab); + return; + } + + // Async move: screenshot → move → placeholder → activate + const targetWindowId = window.id; + runTabSyncMutation(async () => { + if (window.destroyed || focusedTab.isDestroyed) return; + if (focusedTab.getWindow().id === targetWindowId) return; // already moved + + clearPlaceholderInRenderer(targetWindowId); + await moveTabToWindowIfNeeded(focusedTab, window); + + if (focusedTab.isDestroyed || window.destroyed) return; + + tabService.activateTab(focusedTab); + }).catch((err) => { + console.error("[tab-sync] Failed to move active tab on focus:", err); + }); + }); + + // When a window switches away from a synced tab, release it to another + // window that still wants it (has it as its focused tab in the same space). + tabService.on("active-changed", (windowId, spaceId) => { + // Reconcile placeholders for ALL windows (a tab may have moved between + // spaces, making placeholders in other windows stale). + const allWindows = browserWindowsController.getWindows().filter((w) => w.browserWindowType === "normal"); + for (const win of allWindows) { + if (!win.destroyed) reconcilePlaceholderForWindow(win.id); + } + + // Find tabs in this window+space that are no longer active but are wanted elsewhere + for (const otherWin of allWindows) { + if (otherWin.id === windowId || otherWin.destroyed) continue; + if (otherWin.currentSpaceId !== spaceId) continue; + if (!shouldSyncSharedActiveTab(otherWin, spaceId)) continue; + + const wantedTab = tabService.getFocusedTab(otherWin.id, spaceId); + if (!wantedTab || wantedTab.isDestroyed) continue; + if (isSyncExcludedTab(wantedTab)) continue; + // Only release if the tab is in this window and no longer active here + if (wantedTab.getWindow().id !== windowId) continue; + if (tabService.isTabActive(wantedTab)) continue; + + // Move it to the window that wants it + runTabSyncMutation(async () => { + if (otherWin.destroyed || wantedTab.isDestroyed) return; + if (wantedTab.getWindow().id === otherWin.id) return; // already there + + await moveTabToWindowIfNeeded(wantedTab, otherWin); + + if (!wantedTab.isDestroyed && !otherWin.destroyed) { + tabService.activateTab(wantedTab); + } + }).catch((err) => { + console.error("[tab-sync] Failed to release synced tab:", err); + }); + } + }); + + tabService.on("focused-tab-changed", (windowId) => { + reconcilePlaceholderForWindow(windowId); + }); + + // Handle space changes + const handleSpaceChange = (windowId: number) => { + reconcilePlaceholderForWindow(windowId); + + const window = browserWindowsController.getWindowById(windowId); + if (window && window.browserWindowType === "normal") { + const expectedSpaceId = window.currentSpaceId; + if (expectedSpaceId && shouldSyncSharedActiveTab(window, expectedSpaceId)) { + const isStale = () => window.currentSpaceId !== expectedSpaceId; + + runTabSyncMutation(async () => { + if (window.destroyed || isStale()) return; + await moveActiveTabToWindow(window, isStale); + if (isStale()) return; + + const focusedTab = tabService.getFocusedTab(window.id, expectedSpaceId); + if (focusedTab) { + tabService.activateTab(focusedTab); + } + }).catch((err) => { + console.error("[tab-sync] Failed to move active tab on space change:", err); + }); + } + } + }; + + // Listen for new windows being added, and wire space-change listener + const wireWindowSpaceChange = (window: BrowserWindow) => { + window.on("current-space-changed", () => { + handleSpaceChange(window.id); + }); + }; + + // Wire existing windows + for (const win of browserWindowsController.getWindows()) { + wireWindowSpaceChange(win); + } + + // Wire future windows via windowsController + windowsController.on("window-added", (id) => { + const win = browserWindowsController.getWindowById(id); + if (win && win.browserWindowType === "normal") { + wireWindowSpaceChange(win); + } + }); + + // Clear placeholders when tabs are destroyed + tabService.on("tab-removed", (tab) => { + clearPlaceholdersForTab(tab.id); + }); + + // Clean up when windows are destroyed + windowsController.on("window-removed", (id) => { + clearPlaceholderInRenderer(id); + windowPlaceholderGeneration.delete(id); + }); +} + +export { runTabSyncMutation }; diff --git a/src/preload/index.ts b/src/preload/index.ts index 9569da02b..08ac9cd96 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -13,8 +13,6 @@ import type { SpaceData } from "@/controllers/spaces-controller"; // SHARED TYPES // import type { SharedExtensionData } from "~/types/extensions"; -import type { TabData, WindowTabsData } from "~/types/tabs"; -import type { PinnedTabData } from "~/types/pinned-tabs"; import type { UpdateStatus } from "~/types/updates"; import type { WindowState } from "~/flow/types"; @@ -34,8 +32,7 @@ import type { FlowOmniboxAPI, OmniboxOpenParams } from "~/flow/interfaces/browse import { FlowSettingsAPI } from "~/flow/interfaces/settings/settings"; import { FlowWindowsAPI } from "~/flow/interfaces/app/windows"; import { FlowExtensionsAPI } from "~/flow/interfaces/app/extensions"; -import { FlowTabsAPI } from "~/flow/interfaces/browser/tabs"; -import { FlowPinnedTabsAPI } from "~/flow/interfaces/browser/pinned-tabs"; + import { FlowUpdatesAPI } from "~/flow/interfaces/app/updates"; import { FlowActionsAPI } from "~/flow/interfaces/app/actions"; import { FlowShortcutsAPI, ShortcutsData } from "~/flow/interfaces/app/shortcuts"; @@ -45,6 +42,7 @@ import { FlowPasskeyAPI } from "~/flow/interfaces/browser/passkey"; import type { ConditionalPasskeyRequest, PasskeyCredential } from "~/types/passkey"; import { FlowPromptsAPI } from "~/flow/interfaces/browser/prompts"; import type { ActivePrompt } from "~/types/prompts"; +import { createTabServicePreloadAPI } from "@/services/tab-service/ipc/preload-api"; // const isIFrame = !process.isMainFrame; @@ -239,105 +237,6 @@ const browserAPI: FlowBrowserAPI = { } }; -// TABS API // -const tabsAPI: FlowTabsAPI = { - getData: async () => { - return ipcRenderer.invoke("tabs:get-data"); - }, - onDataUpdated: (callback: (data: WindowTabsData) => void) => { - return listenOnIPCChannel("tabs:on-data-changed", callback); - }, - onTabsContentUpdated: (callback: (tabs: TabData[]) => void) => { - return listenOnIPCChannel("tabs:on-tabs-content-updated", callback); - }, - onPlaceholderChanged: (callback) => { - return listenOnIPCChannel("tabs:on-placeholder-changed", callback); - }, - onTargetUrlChanged: (callback) => { - return listenOnIPCChannel("tabs:on-target-url", callback); - }, - switchToTab: async (tabId: number) => { - return ipcRenderer.invoke("tabs:switch-to-tab", tabId); - }, - closeTab: async (tabId: number) => { - return ipcRenderer.invoke("tabs:close-tab", tabId); - }, - - showContextMenu: (tabId: number) => { - return ipcRenderer.send("tabs:show-context-menu", tabId); - }, - - moveTab: async (tabId: number, newPosition: number) => { - return ipcRenderer.invoke("tabs:move-tab", tabId, newPosition); - }, - - moveTabToWindowSpace: async (tabId: number, spaceId: string, newPosition?: number) => { - return ipcRenderer.invoke("tabs:move-tab-to-window-space", tabId, spaceId, newPosition); - }, - - // Special Exception: This is allowed for all internal protocols. - newTab: async (url?: string, isForeground?: boolean, spaceId?: string, typedFromAddressBar?: boolean) => { - return ipcRenderer.invoke("tabs:new-tab", url, isForeground, spaceId, typedFromAddressBar); - }, - - // Special Exception: This is allowed on every tab, but very tightly secured. - // It will only work if the tab is currently in Picture-in-Picture mode. - disablePictureInPicture: async (goBackToTab: boolean) => { - return ipcRenderer.invoke("tabs:disable-picture-in-picture", goBackToTab); - }, - - setTabMuted: async (tabId: number, muted: boolean) => { - return ipcRenderer.invoke("tabs:set-tab-muted", tabId, muted); - }, - - batchMoveTabs: async (tabIds: number[], spaceId: string, newPositionStart?: number) => { - return ipcRenderer.invoke("tabs:batch-move-tabs", tabIds, spaceId, newPositionStart); - }, - - getRecentlyClosed: async () => { - return ipcRenderer.invoke("tabs:get-recently-closed"); - }, - - restoreRecentlyClosed: async (uniqueId: string) => { - return ipcRenderer.invoke("tabs:restore-recently-closed", uniqueId); - }, - - clearRecentlyClosed: async () => { - return ipcRenderer.invoke("tabs:clear-recently-closed"); - } -}; - -// PINNED TABS API // -const pinnedTabsAPI: FlowPinnedTabsAPI = { - getData: async () => { - return ipcRenderer.invoke("pinned-tabs:get-data"); - }, - onChanged: (callback: (data: Record) => void) => { - return listenOnIPCChannel("pinned-tabs:on-changed", callback); - }, - createFromTab: async (tabId: number, position?: number) => { - return ipcRenderer.invoke("pinned-tabs:create-from-tab", tabId, position); - }, - click: async (pinnedTabId: string) => { - return ipcRenderer.invoke("pinned-tabs:click", pinnedTabId); - }, - doubleClick: async (pinnedTabId: string) => { - return ipcRenderer.invoke("pinned-tabs:double-click", pinnedTabId); - }, - remove: async (pinnedTabId: string) => { - return ipcRenderer.invoke("pinned-tabs:remove", pinnedTabId); - }, - unpinToTabList: async (pinnedTabId: string, position?: number) => { - return ipcRenderer.invoke("pinned-tabs:unpin-to-tab-list", pinnedTabId, position); - }, - reorder: async (pinnedTabId: string, newPosition: number) => { - return ipcRenderer.invoke("pinned-tabs:reorder", pinnedTabId, newPosition); - }, - showContextMenu: (pinnedTabId: string) => { - return ipcRenderer.send("pinned-tabs:show-context-menu", pinnedTabId); - } -}; - // PAGE API // const pageAPI: FlowPageAPI = { setPageBounds: (bounds: { x: number; y: number; width: number; height: number }) => { @@ -801,11 +700,7 @@ const flowAPI: typeof flow = { // Browser APIs browser: wrapAPI(browserAPI, "browser"), - tabs: wrapAPI(tabsAPI, "browser", { - newTab: "app", - disablePictureInPicture: "all" - }), - pinnedTabs: wrapAPI(pinnedTabsAPI, "browser"), + page: wrapAPI(pageAPI, "browser"), navigation: wrapAPI(navigationAPI, "browser"), history: wrapAPI(historyAPI, "browser"), @@ -818,6 +713,10 @@ const flowAPI: typeof flow = { newTab: wrapAPI(newTabAPI, "browser"), findInPage: wrapAPI(findInPageAPI, "browser"), prompts: wrapAPI(promptsAPI, "browser"), + tabService: wrapAPI(createTabServicePreloadAPI(ipcRenderer, listenOnIPCChannel), "browser", { + newTab: "app", + disablePictureInPicture: "all" + }), // Session APIs profiles: wrapAPI(profilesAPI, "session", { diff --git a/src/renderer/src/components/browser-ui/browser-content.tsx b/src/renderer/src/components/browser-ui/browser-content.tsx index 1389dbfa7..aa9025b9a 100644 --- a/src/renderer/src/components/browser-ui/browser-content.tsx +++ b/src/renderer/src/components/browser-ui/browser-content.tsx @@ -4,7 +4,7 @@ import { cn } from "@/lib/utils"; import { useBrowserSidebar } from "@/components/browser-ui/browser-sidebar/provider"; import { useAdaptiveTopbar } from "@/components/browser-ui/adaptive-topbar"; import { useSpaces } from "@/components/providers/spaces-provider"; -import type { TabPlaceholderUpdate } from "~/types/tabs"; +import type { TabPlaceholderUpdate } from "~/types/tab-service"; import "./browser-content.css"; const PLACEHOLDER_CLEAR_DELAY_MS = 180; @@ -43,7 +43,7 @@ function BrowserContent() { } }; - const unsub = flow.tabs.onPlaceholderChanged(({ snapshotId, generation, spaceId }: TabPlaceholderUpdate) => { + const unsub = flow.tabService.onPlaceholderChanged(({ snapshotId, generation, spaceId }: TabPlaceholderUpdate) => { if (spaceId !== currentSpaceIdRef.current) { return; } @@ -144,7 +144,7 @@ function BrowserContent() { return (
setPlaceholderSnapshotId(null)} - className="absolute inset-0 w-full h-full rounded-md object-fill opacity-50 pointer-events-none" + className="absolute inset-0 w-full h-full object-fill opacity-50 pointer-events-none" /> )}
diff --git a/src/renderer/src/components/browser-ui/browser-sidebar/_components/bottom/bottom-extras-menu.tsx b/src/renderer/src/components/browser-ui/browser-sidebar/_components/bottom/bottom-extras-menu.tsx index 0341db603..2d46aeb39 100644 --- a/src/renderer/src/components/browser-ui/browser-sidebar/_components/bottom/bottom-extras-menu.tsx +++ b/src/renderer/src/components/browser-ui/browser-sidebar/_components/bottom/bottom-extras-menu.tsx @@ -36,7 +36,7 @@ export function BottomExtrasMenu() { if (url === "internal://settings") { flow.windows.openSettingsWindow(); } else { - flow.tabs.newTab(url, true); + flow.tabService.newTab(url, true); } setOpen(false); }, []); diff --git a/src/renderer/src/components/browser-ui/browser-sidebar/_components/bottom/space-switcher.tsx b/src/renderer/src/components/browser-ui/browser-sidebar/_components/bottom/space-switcher.tsx index f594e1bed..73409c289 100644 --- a/src/renderer/src/components/browser-ui/browser-sidebar/_components/bottom/space-switcher.tsx +++ b/src/renderer/src/components/browser-ui/browser-sidebar/_components/bottom/space-switcher.tsx @@ -3,7 +3,7 @@ import { cn } from "@/lib/utils"; import { useSpaces } from "@/components/providers/spaces-provider"; import { SpaceIcon } from "@/lib/phosphor-icons"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import type { TabGroupSourceData } from "@/components/browser-ui/browser-sidebar/_components/tab-group"; +import type { TabLayoutNodeSourceData } from "@/components/browser-ui/browser-sidebar/_components/tab-layout-node"; import { dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter"; import { AnimatePresence, motion } from "motion/react"; @@ -67,8 +67,8 @@ function SpaceButton({ space, isActive, compact }: SpaceButtonProps) { return dropTargetForElements({ element, canDrop: (args) => { - const sourceData = args.source.data as TabGroupSourceData; - if (sourceData.type !== "tab-group") return false; + const sourceData = args.source.data as TabLayoutNodeSourceData; + if (sourceData.type !== "tab-layout-node") return false; const sourceProfileId = sourceData.profileId; const targetProfileId = space.profileId; @@ -88,9 +88,9 @@ function SpaceButton({ space, isActive, compact }: SpaceButtonProps) { stopDragging(); // Move the tab to this space (no specific position — append to end) - const sourceData = args.source.data as TabGroupSourceData; + const sourceData = args.source.data as TabLayoutNodeSourceData; const sourceTabId = sourceData.primaryTabId; - flow.tabs.moveTabToWindowSpace(sourceTabId, space.id); + flow.tabService.moveTabToSpace(sourceTabId, space.id); } }); }, [onClick, removeDraggingTimeout, space.profileId, space.id]); diff --git a/src/renderer/src/components/browser-ui/browser-sidebar/_components/browser-action-list.tsx b/src/renderer/src/components/browser-ui/browser-sidebar/_components/browser-action-list.tsx index 628506ee0..b335e8289 100644 --- a/src/renderer/src/components/browser-ui/browser-sidebar/_components/browser-action-list.tsx +++ b/src/renderer/src/components/browser-ui/browser-sidebar/_components/browser-action-list.tsx @@ -156,7 +156,7 @@ export function BrowserActionList() { const alignment = useMemo(() => "right bottom", []); const openExtensionsPage = useCallback(() => { - flow.tabs.newTab("flow://extensions", true); + flow.tabService.newTab("flow://extensions", true); setOpen(false); }, []); diff --git a/src/renderer/src/components/browser-ui/browser-sidebar/_components/drag-utils.ts b/src/renderer/src/components/browser-ui/browser-sidebar/_components/drag-utils.ts index b5ebad755..dafed74ab 100644 --- a/src/renderer/src/components/browser-ui/browser-sidebar/_components/drag-utils.ts +++ b/src/renderer/src/components/browser-ui/browser-sidebar/_components/drag-utils.ts @@ -1,10 +1,10 @@ import type { PinnedTabSourceData } from "@/components/browser-ui/browser-sidebar/_components/pin-grid/pinned-tab-button"; -import type { TabGroupSourceData } from "@/components/browser-ui/browser-sidebar/_components/tab-group"; +import type { TabLayoutNodeSourceData } from "@/components/browser-ui/browser-sidebar/_components/tab-layout-node"; export function isPinnedTabSource(data: Record): data is PinnedTabSourceData { return data.type === "pinned-tab" && typeof data.pinnedTabId === "string" && typeof data.profileId === "string"; } -export function isTabGroupSource(data: Record): data is TabGroupSourceData { - return data.type === "tab-group" && typeof data.primaryTabId === "number"; +export function isTabLayoutNodeSource(data: Record): data is TabLayoutNodeSourceData { + return data.type === "tab-layout-node" && typeof data.primaryTabId === "number"; } diff --git a/src/renderer/src/components/browser-ui/browser-sidebar/_components/pin-grid/normal/pin-grid.tsx b/src/renderer/src/components/browser-ui/browser-sidebar/_components/pin-grid/normal/pin-grid.tsx index 2b3ea89ea..524b741e0 100644 --- a/src/renderer/src/components/browser-ui/browser-sidebar/_components/pin-grid/normal/pin-grid.tsx +++ b/src/renderer/src/components/browser-ui/browser-sidebar/_components/pin-grid/normal/pin-grid.tsx @@ -87,7 +87,7 @@ export function PinGrid({ profileId }: PinGridProps) { key={pinnedTab.uniqueId} pinnedTab={pinnedTab} profileId={profileId} - isActive={currentSpace !== null && pinnedTab.associatedTabIdsBySpace[currentSpace.id] === focusedTabId} + isActive={currentSpace !== null && pinnedTab.associatedTabIds[currentSpace.id] === focusedTabId} onClick={() => click(pinnedTab.uniqueId)} onDoubleClick={() => doubleClick(pinnedTab.uniqueId)} onContextMenu={() => showContextMenu(pinnedTab.uniqueId)} diff --git a/src/renderer/src/components/browser-ui/browser-sidebar/_components/pin-grid/normal/use-pin-grid-drop-target.ts b/src/renderer/src/components/browser-ui/browser-sidebar/_components/pin-grid/normal/use-pin-grid-drop-target.ts index 49dc2af83..fc34eb092 100644 --- a/src/renderer/src/components/browser-ui/browser-sidebar/_components/pin-grid/normal/use-pin-grid-drop-target.ts +++ b/src/renderer/src/components/browser-ui/browser-sidebar/_components/pin-grid/normal/use-pin-grid-drop-target.ts @@ -1,6 +1,9 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter"; -import { isPinnedTabSource, isTabGroupSource } from "@/components/browser-ui/browser-sidebar/_components/drag-utils"; +import { + isPinnedTabSource, + isTabLayoutNodeSource +} from "@/components/browser-ui/browser-sidebar/_components/drag-utils"; import { findClosestPinEdge, type GridIndicator } from "./find-closest-pin-edge"; interface UsePinGridDropTargetOptions { @@ -14,7 +17,7 @@ interface UsePinGridDropTargetOptions { /** * Manages all drag-and-drop state and behaviour for the pin grid: - * - drop target registration (accepts pinned-tab reorders & tab-group creates) + * - drop target registration (accepts pinned-tab reorders & layout-node pin creates) * - grid-level indicator (cursor in the gap between pins) * - child-level indicator (cursor directly over a PinnedTabButton) * - unified `activeIndicator` (child takes priority) @@ -83,14 +86,14 @@ export function usePinGridDropTarget({ if (isPinnedTabSource(data)) { return data.profileId === profileId; } - if (isTabGroupSource(data)) { + if (isTabLayoutNodeSource(data)) { if (profileId && data.profileId !== profileId) return false; return true; } return false; }, onDragEnter: ({ location, source }) => { - if (isTabGroupSource(source.data)) { + if (isTabLayoutNodeSource(source.data)) { setIsDragOver(true); } const { input, dropTargets } = location.current; @@ -139,7 +142,7 @@ export function usePinGridDropTarget({ const targets = location.current.dropTargets; if (targets.length > 1 && targets[0].element !== el) return; - if (isTabGroupSource(data)) { + if (isTabLayoutNodeSource(data)) { if (indicator) { const position = indicator.edge === "left" ? indicator.index - 0.5 : indicator.index + 0.5; handleCreateFromTab(data.primaryTabId, position); diff --git a/src/renderer/src/components/browser-ui/browser-sidebar/_components/pin-grid/pinned-tab-button.tsx b/src/renderer/src/components/browser-ui/browser-sidebar/_components/pin-grid/pinned-tab-button.tsx index 163deed72..7f563f164 100644 --- a/src/renderer/src/components/browser-ui/browser-sidebar/_components/pin-grid/pinned-tab-button.tsx +++ b/src/renderer/src/components/browser-ui/browser-sidebar/_components/pin-grid/pinned-tab-button.tsx @@ -4,8 +4,11 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { draggable, dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter"; import { attachClosestEdge, extractClosestEdge } from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge"; import { motion } from "motion/react"; -import type { PinnedTabData } from "~/types/pinned-tabs"; -import { isPinnedTabSource, isTabGroupSource } from "@/components/browser-ui/browser-sidebar/_components/drag-utils"; +import type { PinnedTabData } from "~/types/tab-service"; +import { + isPinnedTabSource, + isTabLayoutNodeSource +} from "@/components/browser-ui/browser-sidebar/_components/drag-utils"; import { generateBorderGradient } from "@/components/browser-ui/browser-sidebar/_components/pin-grid/pin-visual"; import "./pin.css"; @@ -110,7 +113,7 @@ export function PinnedTabButton({ if (isPinnedTabSource(data)) { return !profileId || data.profileId === profileId; } - if (isTabGroupSource(data)) { + if (isTabLayoutNodeSource(data)) { // Only accept tabs from the same profile return !profileId || data.profileId === profileId; } @@ -146,7 +149,7 @@ export function PinnedTabButton({ if (isPinnedTabSource(sourceData)) { onReorder(sourceData.pinnedTabId, newPosition); - } else if (isTabGroupSource(sourceData)) { + } else if (isTabLayoutNodeSource(sourceData)) { onCreateFromTab(sourceData.primaryTabId, newPosition); } } diff --git a/src/renderer/src/components/browser-ui/browser-sidebar/_components/pin-grid/slot-machine/main.tsx b/src/renderer/src/components/browser-ui/browser-sidebar/_components/pin-grid/slot-machine/main.tsx index 80adca75a..9e1a9e803 100644 --- a/src/renderer/src/components/browser-ui/browser-sidebar/_components/pin-grid/slot-machine/main.tsx +++ b/src/renderer/src/components/browser-ui/browser-sidebar/_components/pin-grid/slot-machine/main.tsx @@ -51,31 +51,31 @@ function openWinnerTabs(domains: [string, string, string]) { if (a === b && b === c) { // Jackpot: all 3 same -> open 9 tabs for (let i = 0; i < 9; i++) { - flow.tabs.newTab(url(a), false); + flow.tabService.newTab(url(a), false); } } else if (a === b) { // 2 match (a, b) + 1 different (c) for (let i = 0; i < 4; i++) { - flow.tabs.newTab(url(a), false); + flow.tabService.newTab(url(a), false); } - flow.tabs.newTab(url(c), false); + flow.tabService.newTab(url(c), false); } else if (a === c) { // 2 match (a, c) + 1 different (b) for (let i = 0; i < 4; i++) { - flow.tabs.newTab(url(a), false); + flow.tabService.newTab(url(a), false); } - flow.tabs.newTab(url(b), false); + flow.tabService.newTab(url(b), false); } else if (b === c) { // 2 match (b, c) + 1 different (a) for (let i = 0; i < 4; i++) { - flow.tabs.newTab(url(b), false); + flow.tabService.newTab(url(b), false); } - flow.tabs.newTab(url(a), false); + flow.tabService.newTab(url(a), false); } else { // All different -> open 1 tab each - flow.tabs.newTab(url(a), false); - flow.tabs.newTab(url(b), false); - flow.tabs.newTab(url(c), false); + flow.tabService.newTab(url(a), false); + flow.tabService.newTab(url(b), false); + flow.tabService.newTab(url(c), false); } } diff --git a/src/renderer/src/components/browser-ui/browser-sidebar/_components/site-controls/extensions.tsx b/src/renderer/src/components/browser-ui/browser-sidebar/_components/site-controls/extensions.tsx index a3e9f22e7..d3a45003c 100644 --- a/src/renderer/src/components/browser-ui/browser-sidebar/_components/site-controls/extensions.tsx +++ b/src/renderer/src/components/browser-ui/browser-sidebar/_components/site-controls/extensions.tsx @@ -207,7 +207,7 @@ export function ExtensionsList({ setOpen }: { setOpen: (open: boolean) => void } { event.stopPropagation(); - flow.tabs.newTab(CHROME_WEB_STORE_URL, true); + flow.tabService.newTab(CHROME_WEB_STORE_URL, true); setOpen(false); }} > @@ -232,7 +232,7 @@ export function SiteControlExtensions({ setOpen }: { setOpen: (open: boolean) => )} tabIndex={-1} onClick={(event) => { - flow.tabs.newTab("flow://extensions", true); + flow.tabService.newTab("flow://extensions", true); setOpen(false); event.stopPropagation(); }} diff --git a/src/renderer/src/components/browser-ui/browser-sidebar/_components/space-pages-carousel.tsx b/src/renderer/src/components/browser-ui/browser-sidebar/_components/space-pages-carousel.tsx index 28a3608bb..485612cbc 100644 --- a/src/renderer/src/components/browser-ui/browser-sidebar/_components/space-pages-carousel.tsx +++ b/src/renderer/src/components/browser-ui/browser-sidebar/_components/space-pages-carousel.tsx @@ -1,11 +1,11 @@ import { useSpaces } from "@/components/providers/spaces-provider"; -import { useTabsGroups } from "@/components/providers/tabs-provider"; +import { useTabLayoutNodes } from "@/components/providers/tabs-provider"; import { usePinnedTabs } from "@/components/providers/pinned-tabs-provider"; import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef } from "react"; import { SidebarScrollArea } from "./sidebar-scroll-area"; import { SpaceTitle } from "./space-title"; import { NewTabButton } from "./new-tab-button"; -import { TabGroup } from "./tab-group"; +import { TabLayoutNode } from "./tab-layout-node"; import { TabDropTarget } from "./tab-drop-target"; import { AnimatePresence } from "motion/react"; import type { Space } from "~/flow/interfaces/sessions/spaces"; @@ -14,7 +14,7 @@ import { PinGrid } from "@/components/browser-ui/browser-sidebar/_components/pin import { useBrowserSidebar } from "@/components/browser-ui/browser-sidebar/provider"; // --- SpaceContentPage --- // -// Renders the full content for a single space: title, scroll area with tab groups, and drop target. +// Renders the full content for a single space: title, scroll area with layout nodes, and drop target. interface SpaceContentPageProps { space: Space; @@ -29,16 +29,16 @@ const SpaceContentPage = memo(function SpaceContentPage({ slotMachineEnabled, withinCarousel = true }: SpaceContentPageProps) { - const { getTabGroups, getActiveTabGroup, getFocusedTab } = useTabsGroups(); + const { getLayoutNodes, getActiveLayoutNode, getFocusedTab } = useTabLayoutNodes(); const { unpinToTabList } = usePinnedTabs(); const { isProfileEphemeral } = useSpaces(); const isSpaceLight = useMemo(() => hex_is_light(space.bgStartColor || "#000000"), [space.bgStartColor]); const shouldShowPinnedTabs = !isProfileEphemeral(space.profileId); // Ephemeral tabs (pinned-tab-associated) are already filtered out by the - // tabs provider, so getTabGroups returns only visible tab groups. - const sortedTabGroups = useMemo(() => getTabGroups(space.id), [space.id, getTabGroups]); - const activeTabGroup = useMemo(() => getActiveTabGroup(space.id), [getActiveTabGroup, space.id]); + // tabs provider, so getLayoutNodes returns only visible sidebar layout nodes. + const sortedLayoutNodes = useMemo(() => getLayoutNodes(space.id), [space.id, getLayoutNodes]); + const activeLayoutNode = useMemo(() => getActiveLayoutNode(space.id), [getActiveLayoutNode, space.id]); const focusedTab = useMemo(() => getFocusedTab(space.id), [getFocusedTab, space.id]); return ( @@ -54,15 +54,15 @@ const SpaceContentPage = memo(function SpaceContentPage({
- {sortedTabGroups.map((tabGroup) => ( - tab.id === focusedTab.id)} + {sortedLayoutNodes.map((layoutNode) => ( + tab.id === focusedTab.id)} isSpaceLight={isSpaceLight} - position={tabGroup.position} - groupCount={sortedTabGroups.length} + position={layoutNode.position} + layoutNodeCount={sortedLayoutNodes.length} moveTab={moveTab} unpinToTabList={unpinToTabList} /> @@ -72,7 +72,7 @@ const SpaceContentPage = memo(function SpaceContentPage({ spaceData={space} isSpaceLight={isSpaceLight} moveTab={moveTab} - biggestIndex={sortedTabGroups.length > 0 ? sortedTabGroups[sortedTabGroups.length - 1].position : -1} + biggestIndex={sortedLayoutNodes.length > 0 ? sortedLayoutNodes[sortedLayoutNodes.length - 1].position : -1} />
@@ -109,7 +109,7 @@ export function SpacePagesCarousel() { }, []); const moveTab = useCallback((tabId: number, newPosition: number) => { - flow.tabs.moveTab(tabId, newPosition); + flow.tabService.moveTab(tabId, newPosition); }, []); const currentIndex = useMemo(() => { diff --git a/src/renderer/src/components/browser-ui/browser-sidebar/_components/tab-drop-target.tsx b/src/renderer/src/components/browser-ui/browser-sidebar/_components/tab-drop-target.tsx index 589dc8d10..93138a774 100644 --- a/src/renderer/src/components/browser-ui/browser-sidebar/_components/tab-drop-target.tsx +++ b/src/renderer/src/components/browser-ui/browser-sidebar/_components/tab-drop-target.tsx @@ -1,4 +1,4 @@ -import { TabGroupSourceData } from "@/components/browser-ui/browser-sidebar/_components/tab-group"; +import { TabLayoutNodeSourceData } from "@/components/browser-ui/browser-sidebar/_components/tab-layout-node"; import { DropIndicator } from "@/components/browser-ui/browser-sidebar/_components/drop-indicator"; import { useEffect, useRef, useState } from "react"; import { Space } from "~/flow/interfaces/sessions/spaces"; @@ -40,16 +40,16 @@ export function TabDropTarget({ spaceData, isSpaceLight, moveTab, biggestIndex } return; } - const tabGroupData = sourceData as TabGroupSourceData; - const sourceTabId = tabGroupData.primaryTabId; + const layoutNodeData = sourceData as TabLayoutNodeSourceData; + const sourceTabId = layoutNodeData.primaryTabId; const newPos = biggestIndex + 1; - if (tabGroupData.spaceId !== spaceData.id) { - if (tabGroupData.profileId !== spaceData.profileId) { + if (layoutNodeData.spaceId !== spaceData.id) { + if (layoutNodeData.profileId !== spaceData.profileId) { // TODO: @MOVE_TABS_BETWEEN_PROFILES not supported yet } else { - flow.tabs.moveTabToWindowSpace(sourceTabId, spaceData.id, newPos); + flow.tabService.moveTabToSpace(sourceTabId, spaceData.id, newPos); } } else { moveTab(sourceTabId, newPos); @@ -70,12 +70,12 @@ export function TabDropTarget({ spaceData, isSpaceLight, moveTab, biggestIndex } return sourceData.profileId === spaceData.profileId; } - // Accept tab group drags (existing behavior) - const tabGroupData = sourceData as TabGroupSourceData; - if (tabGroupData.type !== "tab-group") { + // Accept layout-node drags (sidebar tab reorder / cross-space move) + const layoutNodeData = sourceData as TabLayoutNodeSourceData; + if (layoutNodeData.type !== "tab-layout-node") { return false; } - if (tabGroupData.profileId !== spaceData.profileId) { + if (layoutNodeData.profileId !== spaceData.profileId) { // TODO: @MOVE_TABS_BETWEEN_PROFILES not supported yet return false; } diff --git a/src/renderer/src/components/browser-ui/browser-sidebar/_components/tab-group.tsx b/src/renderer/src/components/browser-ui/browser-sidebar/_components/tab-layout-node.tsx similarity index 81% rename from src/renderer/src/components/browser-ui/browser-sidebar/_components/tab-group.tsx rename to src/renderer/src/components/browser-ui/browser-sidebar/_components/tab-layout-node.tsx index 8ad0a046c..cb8f57476 100644 --- a/src/renderer/src/components/browser-ui/browser-sidebar/_components/tab-group.tsx +++ b/src/renderer/src/components/browser-ui/browser-sidebar/_components/tab-layout-node.tsx @@ -2,8 +2,8 @@ import { cn, craftActiveFaviconURL } from "@/lib/utils"; import { XIcon, Volume2, VolumeX } from "lucide-react"; import { memo, useCallback, useEffect, useRef, useState } from "react"; import { motion, AnimatePresence } from "motion/react"; -import type { TabGroup as TabGroupType } from "@/components/providers/tabs-provider"; -import type { TabData } from "~/types/tabs"; +import type { TabLayoutNodeView } from "@/components/providers/tabs-provider"; +import type { TabData } from "~/types/tab-service"; import { draggable, dropTargetForElements, @@ -15,21 +15,21 @@ import { setCustomNativeDragPreview } from "@atlaskit/pragmatic-drag-and-drop/el import { DropIndicator } from "@/components/browser-ui/browser-sidebar/_components/drop-indicator"; import { isPinnedTabSource } from "@/components/browser-ui/browser-sidebar/_components/drag-utils"; -/** Greater than 1 speeds up tab-group enter, exit, and layout motion. */ -const TAB_GROUP_MOTION_SPEED_MULTIPLIER = 2; +/** Greater than 1 speeds up layout-node enter, exit, and layout motion. */ +const TAB_LAYOUT_NODE_MOTION_SPEED_MULTIPLIER = 2; // --- Types --- // -export type TabGroupSourceData = { - type: "tab-group"; - tabGroupId: string; +export type TabLayoutNodeSourceData = { + type: "tab-layout-node"; + layoutNodeId: string; primaryTabId: number; profileId: string; spaceId: string; position: number; }; -function renderTabGroupDragPreview({ +function renderTabLayoutNodeDragPreview({ container, element, isSpaceLight @@ -88,14 +88,14 @@ const SidebarTab = memo( const handleClick = useCallback(() => { if (!tab.id) return; - flow.tabs.switchToTab(tab.id); + flow.tabService.switchToTab(tab.id); }, [tab.id]); const handleCloseTab = useCallback( (e: React.MouseEvent) => { if (!tab.id) return; e.preventDefault(); - flow.tabs.closeTab(tab.id); + flow.tabService.closeTab(tab.id); }, [tab.id] ); @@ -117,7 +117,7 @@ const SidebarTab = memo( e.stopPropagation(); if (!tab.id) return; const newMutedState = !tab.muted; - flow.tabs.setTabMuted(tab.id, newMutedState); + flow.tabService.setTabMuted(tab.id, newMutedState); }, [tab.id, tab.muted] ); @@ -125,7 +125,8 @@ const SidebarTab = memo( const handleContextMenu = useCallback( (e: React.MouseEvent) => { e.preventDefault(); - flow.tabs.showContextMenu(tab.id); + if (!tab.id) return; + flow.tabService.showContextMenu(tab.id); }, [tab.id] ); @@ -221,27 +222,34 @@ const SidebarTab = memo( } ); -// --- TabGroup (memoized, with drag-and-drop) --- // +// --- TabLayoutNode (memoized, with drag-and-drop) --- // -interface TabGroupProps { - tabGroup: TabGroupType; +interface TabLayoutNodeProps { + layoutNode: TabLayoutNodeView; isActive: boolean; isFocused: boolean; isSpaceLight: boolean; position: number; - groupCount: number; + layoutNodeCount: number; moveTab: (tabId: number, newPosition: number) => void; unpinToTabList: (pinnedTabId: string, position?: number) => Promise; } -export const TabGroup = memo( - function TabGroup({ tabGroup, isFocused, isSpaceLight, position, moveTab, unpinToTabList }: TabGroupProps) { - const { tabs, focusedTab } = tabGroup; +export const TabLayoutNode = memo( + function TabLayoutNode({ + layoutNode, + isFocused, + isSpaceLight, + position, + moveTab, + unpinToTabList + }: TabLayoutNodeProps) { + const { tabs, focusedTab } = layoutNode; const ref = useRef(null); const [closestEdge, setClosestEdge] = useState(null); // Extract stable primitives for the drag-and-drop effect dependencies. - // Previously, tabGroup.tabs (a new array each render) was in the dep array, + // Previously, layoutNode.tabs (a new array each render) was in the dep array, // causing the effect to re-run on every tab data update. const primaryTabId = tabs[0]?.id; @@ -272,8 +280,8 @@ export const TabGroup = memo( return; } - const tabGroupData = sourceData as TabGroupSourceData; - const sourceTabId = tabGroupData.primaryTabId; + const layoutNodeData = sourceData as TabLayoutNodeSourceData; + const sourceTabId = layoutNodeData.primaryTabId; let newPos: number | undefined = undefined; @@ -283,11 +291,11 @@ export const TabGroup = memo( newPos = position + 0.5; } - if (tabGroupData.spaceId !== tabGroup.spaceId) { - if (tabGroupData.profileId !== tabGroup.profileId) { + if (layoutNodeData.spaceId !== layoutNode.spaceId) { + if (layoutNodeData.profileId !== layoutNode.profileId) { // TODO: @MOVE_TABS_BETWEEN_PROFILES not supported yet } else { - flow.tabs.moveTabToWindowSpace(sourceTabId, tabGroup.spaceId, newPos); + flow.tabService.moveTabToSpace(sourceTabId, layoutNode.spaceId, newPos); } } else if (newPos !== undefined) { moveTab(sourceTabId, newPos); @@ -297,12 +305,12 @@ export const TabGroup = memo( const draggableCleanup = draggable({ element: el, getInitialData: () => { - const data: TabGroupSourceData = { - type: "tab-group", - tabGroupId: tabGroup.id, + const data: TabLayoutNodeSourceData = { + type: "tab-layout-node", + layoutNodeId: layoutNode.id, primaryTabId: primaryTabId, - profileId: tabGroup.profileId, - spaceId: tabGroup.spaceId, + profileId: layoutNode.profileId, + spaceId: layoutNode.spaceId, position: position }; return data; @@ -314,7 +322,7 @@ export const TabGroup = memo( element: el, input: location.current.input }), - render: ({ container }) => renderTabGroupDragPreview({ container, element: el, isSpaceLight }) + render: ({ container }) => renderTabLayoutNodeDragPreview({ container, element: el, isSpaceLight }) }); } }); @@ -336,17 +344,17 @@ export const TabGroup = memo( // Accept pinned tab drags (for unpinning) if (isPinnedTabSource(sourceData)) { - return sourceData.profileId === tabGroup.profileId; + return sourceData.profileId === layoutNode.profileId; } - const tabGroupData = sourceData as TabGroupSourceData; - if (tabGroupData.type !== "tab-group") { + const layoutNodeData = sourceData as TabLayoutNodeSourceData; + if (layoutNodeData.type !== "tab-layout-node") { return false; } - if (tabGroupData.tabGroupId === tabGroup.id) { + if (layoutNodeData.layoutNodeId === layoutNode.id) { return false; } - if (tabGroupData.profileId !== tabGroup.profileId) { + if (layoutNodeData.profileId !== layoutNode.profileId) { return false; } return true; @@ -364,11 +372,11 @@ export const TabGroup = memo( }, [ moveTab, unpinToTabList, - tabGroup.id, + layoutNode.id, position, primaryTabId, - tabGroup.spaceId, - tabGroup.profileId, + layoutNode.spaceId, + layoutNode.profileId, isSpaceLight ]); @@ -385,15 +393,15 @@ export const TabGroup = memo( transition={{ layout: { type: "spring", - stiffness: 500 * TAB_GROUP_MOTION_SPEED_MULTIPLIER, - damping: 35 * TAB_GROUP_MOTION_SPEED_MULTIPLIER + stiffness: 500 * TAB_LAYOUT_NODE_MOTION_SPEED_MULTIPLIER, + damping: 35 * TAB_LAYOUT_NODE_MOTION_SPEED_MULTIPLIER }, height: { type: "tween", - duration: 0.2 / TAB_GROUP_MOTION_SPEED_MULTIPLIER, + duration: 0.2 / TAB_LAYOUT_NODE_MOTION_SPEED_MULTIPLIER, ease: "easeOut" }, - opacity: { duration: 0.15 / TAB_GROUP_MOTION_SPEED_MULTIPLIER } + opacity: { duration: 0.15 / TAB_LAYOUT_NODE_MOTION_SPEED_MULTIPLIER } }} style={{ overflow: "hidden" }} className="relative flex flex-col gap-0.5" @@ -415,15 +423,15 @@ export const TabGroup = memo( ); }, - // TabGroup references are stabilized by TabsProvider cache. + // TabLayoutNodeView references are stabilized by TabsProvider cache. (prev, next) => { return ( - prev.tabGroup === next.tabGroup && + prev.layoutNode === next.layoutNode && prev.isActive === next.isActive && prev.isFocused === next.isFocused && prev.isSpaceLight === next.isSpaceLight && prev.position === next.position && - prev.groupCount === next.groupCount && + prev.layoutNodeCount === next.layoutNodeCount && prev.moveTab === next.moveTab && prev.unpinToTabList === next.unpinToTabList ); diff --git a/src/renderer/src/components/browser-ui/main.tsx b/src/renderer/src/components/browser-ui/main.tsx index 3ea8634b8..61ea50d55 100644 --- a/src/renderer/src/components/browser-ui/main.tsx +++ b/src/renderer/src/components/browser-ui/main.tsx @@ -20,7 +20,7 @@ import { useFocusedTab, useFocusedTabFullscreen, useFocusedTabLoading, - useTabsGroups + useTabLayoutNodes } from "@/components/providers/tabs-provider"; import { TabDisabler } from "@/components/logic/tab-disabler"; import { BrowserActionProvider } from "@/components/providers/browser-action-provider"; @@ -114,16 +114,16 @@ const WindowTitle = memo(function WindowTitle() { }); function AutoNewTab({ isReady }: { isReady: boolean }) { - const { tabGroups } = useTabsGroups(); + const { layoutNodes } = useTabLayoutNodes(); const openedNewTabRef = useRef(false); useEffect(() => { if (isReady && !openedNewTabRef.current) { openedNewTabRef.current = true; - if (tabGroups.length === 0) { + if (layoutNodes.length === 0) { flow.newTab.open(); } } - }, [isReady, tabGroups.length]); + }, [isReady, layoutNodes.length]); return null; } diff --git a/src/renderer/src/components/browser-ui/target-url-indicator.tsx b/src/renderer/src/components/browser-ui/target-url-indicator.tsx index 8b28d848d..c8e042369 100644 --- a/src/renderer/src/components/browser-ui/target-url-indicator.tsx +++ b/src/renderer/src/components/browser-ui/target-url-indicator.tsx @@ -5,7 +5,7 @@ import { useBoundingRect } from "@/hooks/use-bounding-rect"; import { useSpaces } from "@/components/providers/spaces-provider"; import { useTabs } from "@/components/providers/tabs-provider"; import { cn } from "@/lib/utils"; -import type { TabTargetUrlUpdate } from "~/types/tabs"; +import type { TabTargetUrlUpdate } from "~/types/tab-service"; import { AnimatePresence, motion } from "motion/react"; import { useUnmount } from "react-use"; import { MailIcon } from "lucide-react"; @@ -142,7 +142,7 @@ export function TargetUrlIndicator({ anchorRef }: TargetUrlIndicatorProps) { const anchorRect = useBoundingRect(anchorRef); useEffect(() => { - return flow.tabs.onTargetUrlChanged((update: TabTargetUrlUpdate) => { + return flow.tabService.onTargetUrlChanged((update: TabTargetUrlUpdate) => { setUrlsByTabId((prev) => { const next = new Map(prev); if (update.url) { diff --git a/src/renderer/src/components/omnibox/main.tsx b/src/renderer/src/components/omnibox/main.tsx index 7d8033f22..4ed4db1e4 100644 --- a/src/renderer/src/components/omnibox/main.tsx +++ b/src/renderer/src/components/omnibox/main.tsx @@ -31,7 +31,7 @@ const OMNIBOX_SHADOW = function commitSuggestion(suggestion: OmniboxSuggestion, openIn: "current" | "new_tab") { switch (suggestion.type) { case "open-tab": - flow.tabs.switchToTab(suggestion.tabId); + flow.tabService.switchToTab(suggestion.tabId); break; case "pedal": { const a = suggestion.action; @@ -42,9 +42,9 @@ function commitSuggestion(suggestion: OmniboxSuggestion, openIn: "current" | "ne } else if (a === "open_incognito_window") { flow.browser.createIncognitoWindow(); } else if (a === "open_extensions") { - flow.tabs.newTab("flow://extensions", true); + flow.tabService.newTab("flow://extensions", true); } else if (a === "open_history") { - flow.tabs.newTab("flow://history", true); + flow.tabService.newTab("flow://history", true); } break; } @@ -53,7 +53,7 @@ function commitSuggestion(suggestion: OmniboxSuggestion, openIn: "current" | "ne if (openIn === "current") { flow.navigation.goTo(url, undefined, true); } else { - flow.tabs.newTab(url, true, undefined, true); + flow.tabService.newTab(url, true, undefined, true); } break; } @@ -62,7 +62,7 @@ function commitSuggestion(suggestion: OmniboxSuggestion, openIn: "current" | "ne if (openIn === "current") { flow.navigation.goTo(url, undefined, true); } else { - flow.tabs.newTab(url, true, undefined, true); + flow.tabService.newTab(url, true, undefined, true); } break; } diff --git a/src/renderer/src/components/providers/pinned-tabs-provider.tsx b/src/renderer/src/components/providers/pinned-tabs-provider.tsx index 6a58dcd1c..51b8bebdc 100644 --- a/src/renderer/src/components/providers/pinned-tabs-provider.tsx +++ b/src/renderer/src/components/providers/pinned-tabs-provider.tsx @@ -1,5 +1,5 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react"; -import type { PinnedTabData } from "~/types/pinned-tabs"; +import type { PinnedTabData } from "~/types/tab-service"; interface PinnedTabsContextValue { /** All pinned tabs grouped by profile ID */ @@ -45,11 +45,11 @@ export const PinnedTabsProvider = ({ children }: PinnedTabsProviderProps) => { // before getData resolves, the stale getData result is discarded. useEffect(() => { let settled = false; - const unsub = flow.pinnedTabs.onChanged((data) => { + const unsub = flow.tabService.onPinnedTabsChanged((data) => { settled = true; setPinnedTabsByProfile(data); }); - flow.pinnedTabs.getData().then((data) => { + flow.tabService.getPinnedTabs().then((data) => { if (!settled) { setPinnedTabsByProfile(data); } @@ -65,19 +65,19 @@ export const PinnedTabsProvider = ({ children }: PinnedTabsProviderProps) => { ); const createFromTab = useCallback(async (tabId: number, position?: number) => { - return flow.pinnedTabs.createFromTab(tabId, position); + return flow.tabService.createPinnedTabFromTab(tabId, position); }, []); const click = useCallback(async (pinnedTabId: string) => { - return flow.pinnedTabs.click(pinnedTabId); + return flow.tabService.clickPinnedTab(pinnedTabId); }, []); const doubleClick = useCallback(async (pinnedTabId: string) => { - return flow.pinnedTabs.doubleClick(pinnedTabId); + return flow.tabService.doubleClickPinnedTab(pinnedTabId); }, []); const unpinToTabList = useCallback(async (pinnedTabId: string, position?: number) => { - return flow.pinnedTabs.unpinToTabList(pinnedTabId, position); + return flow.tabService.unpinToTabList(pinnedTabId, position); }, []); const reorder = useCallback(async (pinnedTabId: string, newPosition: number) => { @@ -99,11 +99,11 @@ export const PinnedTabsProvider = ({ children }: PinnedTabsProviderProps) => { return next; }); - return flow.pinnedTabs.reorder(pinnedTabId, newPosition); + return flow.tabService.reorderPinnedTab(pinnedTabId, newPosition); }, []); const showContextMenu = useCallback((pinnedTabId: string) => { - flow.pinnedTabs.showContextMenu(pinnedTabId); + flow.tabService.showPinnedTabContextMenu(pinnedTabId); }, []); const contextValue = useMemo( diff --git a/src/renderer/src/components/providers/tabs-provider.tsx b/src/renderer/src/components/providers/tabs-provider.tsx index 6f395a6ae..f04b510c5 100644 --- a/src/renderer/src/components/providers/tabs-provider.tsx +++ b/src/renderer/src/components/providers/tabs-provider.tsx @@ -1,43 +1,51 @@ import { useSpaces } from "@/components/providers/spaces-provider"; import { transformUrlToDisplayURL } from "@/lib/url"; import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; -import type { TabData, TabGroupData, WindowTabsData } from "~/types/tabs"; - -export type TabGroup = Omit & { +import type { TabData, TabLayoutNodeData, WindowTabsPayload } from "~/types/tab-service"; + +/** Enriched layout node for sidebar rendering (tabs resolved from payload). */ +export type TabLayoutNodeView = { + id: string; + mode: string; + profileId: string; + spaceId: string; + position: number; + tabIds: number[]; + frontTabId?: number; tabs: TabData[]; active: boolean; focusedTab: TabData | null; }; -type TabGroupCacheEntry = { - source: TabGroupData; +type TabLayoutNodeCacheEntry = { + source: TabLayoutNodeData | null; tabs: TabData[]; active: boolean; focusedTab: TabData | null; - value: TabGroup; + value: TabLayoutNodeView; }; interface TabsContextValue { - tabGroups: TabGroup[]; - getTabGroups: (spaceId: string) => TabGroup[]; - getActiveTabGroup: (spaceId: string) => TabGroup | null; + layoutNodes: TabLayoutNodeView[]; + getLayoutNodes: (spaceId: string) => TabLayoutNodeView[]; + getActiveLayoutNode: (spaceId: string) => TabLayoutNodeView | null; getFocusedTab: (spaceId: string) => TabData | null; // Current Space // - activeTabGroup: TabGroup | null; + activeLayoutNode: TabLayoutNodeView | null; focusedTab: TabData | null; addressUrl: string; // Utilities // - tabsData: WindowTabsData | null; + tabsData: WindowTabsPayload | null; getActiveTabId: (spaceId: string) => number[] | null; getFocusedTabId: (spaceId: string) => number | null; } const TabsContext = createContext(null); -const TabsGroupsContext = createContext | null>(null); const TabsFocusedContext = createContext | null>(null); const TabsFocusedIdContext = createContext(undefined); @@ -52,10 +60,10 @@ export const useTabs = () => { return context; }; -export const useTabsGroups = () => { - const context = useContext(TabsGroupsContext); +export const useTabLayoutNodes = () => { + const context = useContext(TabsLayoutNodesContext); if (!context) { - throw new Error("useTabsGroups must be used within a TabsProvider"); + throw new Error("useTabLayoutNodes must be used within a TabsProvider"); } return context; }; @@ -104,8 +112,8 @@ interface TabsProviderProps { children: React.ReactNode; } -const EMPTY_TAB_GROUPS: TabGroup[] = []; -const EMPTY_TAB_GROUP_CACHE = new Map(); +const EMPTY_LAYOUT_NODES: TabLayoutNodeView[] = []; +const EMPTY_LAYOUT_NODE_CACHE = new Map(); function areSameTabRefs(a: TabData[], b: TabData[]): boolean { if (a.length !== b.length) return false; @@ -117,13 +125,13 @@ function areSameTabRefs(a: TabData[], b: TabData[]): boolean { export const TabsProvider = ({ children }: TabsProviderProps) => { const { currentSpace } = useSpaces(); - const [tabsData, setTabsData] = useState(null); - const tabGroupCacheRef = useRef>(EMPTY_TAB_GROUP_CACHE); + const [tabsData, setTabsData] = useState(null); + const layoutNodeCacheRef = useRef>(EMPTY_LAYOUT_NODE_CACHE); const fetchTabs = useCallback(async () => { if (!flow) return; try { - const data = await flow.tabs.getData(); + const data = await flow.tabService.getData(); setTabsData(data); } catch (error) { console.error("Failed to fetch tabs data:", error); @@ -138,13 +146,13 @@ export const TabsProvider = ({ children }: TabsProviderProps) => { if (!flow) return; // Full data refresh (structural changes: tab created/removed, active tab changed) - const unsubFull = flow.tabs.onDataUpdated((data) => { + const unsubFull = flow.tabService.onDataUpdated((data) => { setTabsData(data); }); // Lightweight content update (title, url, isLoading, etc.) // Merges changed tabs into existing state without replacing the full object. - const unsubContent = flow.tabs.onTabsContentUpdated((updatedTabs) => { + const unsubContent = flow.tabService.onContentUpdated((updatedTabs) => { setTabsData((prev) => { if (!prev) return prev; if (updatedTabs.length === 0) return prev; @@ -176,7 +184,18 @@ export const TabsProvider = ({ children }: TabsProviderProps) => { const getActiveTabId = useCallback( (spaceId: string) => { - return tabsData?.activeTabIds[spaceId] || null; + if (!tabsData) return null; + // Resolve from active layout node + const activeNodeId = tabsData.activeLayoutNodeIds[spaceId]; + if (!activeNodeId) return null; + // Find the node to get its tab IDs + const node = tabsData.layoutNodes.find((n) => n.id === activeNodeId); + if (node) return node.tabIds; + // Single nodes are not serialized in layoutNodes. Main still sends their + // real ln-* node IDs, so resolve active single tabs via the focused tab. + const focusedTabId = tabsData.focusedTabIds[spaceId]; + if (focusedTabId !== undefined) return [focusedTabId]; + return null; }, [tabsData] ); @@ -188,21 +207,21 @@ export const TabsProvider = ({ children }: TabsProviderProps) => { [tabsData] ); - const { tabGroups, tabGroupsBySpaceId, activeTabGroupBySpaceId, focusedTabBySpaceId, nextTabGroupCache } = + const { layoutNodes, layoutNodesBySpaceId, activeLayoutNodeBySpaceId, focusedTabBySpaceId, nextLayoutNodeCache } = useMemo(() => { - const tabGroupsBySpaceId = new Map(); - const activeTabGroupBySpaceId = new Map(); + const layoutNodesBySpaceId = new Map(); + const activeLayoutNodeBySpaceId = new Map(); const focusedTabBySpaceId = new Map(); - const nextTabGroupCache = new Map(); - const previousTabGroupCache = tabGroupCacheRef.current; + const nextLayoutNodeCache = new Map(); + const previousLayoutNodeCache = layoutNodeCacheRef.current; if (!tabsData) { return { - tabGroups: EMPTY_TAB_GROUPS, - tabGroupsBySpaceId, - activeTabGroupBySpaceId, + layoutNodes: EMPTY_LAYOUT_NODES, + layoutNodesBySpaceId, + activeLayoutNodeBySpaceId, focusedTabBySpaceId, - nextTabGroupCache + nextLayoutNodeCache }; } @@ -211,45 +230,73 @@ export const TabsProvider = ({ children }: TabsProviderProps) => { tabById.set(tab.id, tab); } - const allTabGroupDatas: TabGroupData[] = []; - const tabsWithGroups = new Set(); - for (const tabGroup of tabsData.tabGroups ?? []) { - allTabGroupDatas.push(tabGroup); - for (const tabId of tabGroup.tabIds) { - tabsWithGroups.add(tabId); + // Build active node IDs set per space + const activeNodeBySpace = new Map(); + for (const [spaceId, nodeId] of Object.entries(tabsData.activeLayoutNodeIds)) { + activeNodeBySpace.set(spaceId, nodeId); + } + const serializedLayoutNodeIds = new Set(tabsData.layoutNodes.map((node) => node.id)); + + for (const [spaceId, focusedTabId] of Object.entries(tabsData.focusedTabIds)) { + focusedTabBySpaceId.set(spaceId, tabById.get(focusedTabId) ?? null); + } + + // Collect tabs that are part of multi-tab layout nodes + const tabsInNodes = new Set(); + for (const node of tabsData.layoutNodes) { + for (const tabId of node.tabIds) { + tabsInNodes.add(tabId); } } + // Build views from layout nodes (multi-tab: glance/split) + interface InternalLayoutNodeData { + id: string; + mode: string; + profileId: string; + spaceId: string; + tabIds: number[]; + frontTabId?: number; + position: number; + nodeData: TabLayoutNodeData | null; + } + + const allLayoutNodeDatas: InternalLayoutNodeData[] = []; + + for (const node of tabsData.layoutNodes) { + allLayoutNodeDatas.push({ + id: node.id, + mode: node.mode, + profileId: node.profileId, + spaceId: node.spaceId, + tabIds: node.tabIds, + frontTabId: node.frontTabId, + position: node.position, + nodeData: node + }); + } + + // Create synthetic single-tab layout nodes for tabs not in any multi-tab node. + // Skip pinned/bookmark-owned tabs — they appear in the pin grid, not the sidebar. for (const tab of tabsData.tabs) { - if (tabsWithGroups.has(tab.id)) continue; - // Ephemeral tabs (e.g. pinned-tab-associated) are included in tabById - // for focusedTab resolution but should not appear in the sidebar tab list. - if (tab.ephemeral) continue; - allTabGroupDatas.push({ - // Synthetic group ID — uses string format to avoid collision with real group IDs + if (tabsInNodes.has(tab.id)) continue; + if (tab.owner.kind !== "normal") continue; + allLayoutNodeDatas.push({ id: `s-${tab.uniqueId}`, - mode: "normal", + mode: "single", profileId: tab.profileId, spaceId: tab.spaceId, tabIds: [tab.id], - position: tab.position + position: tab.position, + nodeData: null }); } - const activeTabIdsBySpaceId = new Map>(); - for (const [spaceId, activeTabIds] of Object.entries(tabsData.activeTabIds)) { - activeTabIdsBySpaceId.set(spaceId, new Set(activeTabIds)); - } - - for (const [spaceId, focusedTabId] of Object.entries(tabsData.focusedTabIds)) { - focusedTabBySpaceId.set(spaceId, tabById.get(focusedTabId) ?? null); - } - - const tabGroups: TabGroup[] = []; + const layoutNodes: TabLayoutNodeView[] = []; - for (const tabGroupData of allTabGroupDatas) { + for (const nodeData of allLayoutNodeDatas) { const tabs: TabData[] = []; - for (const tabId of tabGroupData.tabIds) { + for (const tabId of nodeData.tabIds) { const tab = tabById.get(tabId); if (tab) { tabs.push(tab); @@ -258,56 +305,76 @@ export const TabsProvider = ({ children }: TabsProviderProps) => { if (tabs.length === 0) continue; - const activeTabIds = activeTabIdsBySpaceId.get(tabGroupData.spaceId); - const isActive = tabs.some((tab) => activeTabIds?.has(tab.id)); - const focusedTab = focusedTabBySpaceId.get(tabGroupData.spaceId) ?? null; + const activeNodeId = activeNodeBySpace.get(nodeData.spaceId); + // For synthetic single-tab nodes, check if any of their tabs match the active node + let isActive = false; + if (activeNodeId) { + if (nodeData.id === activeNodeId) { + isActive = true; + } else if (nodeData.mode === "single" && !serializedLayoutNodeIds.has(activeNodeId)) { + // Main omits single nodes from layoutNodes but still reports their + // real ln-* IDs. The focused tab is the active single node's tab. + const focusedTabId = tabsData.focusedTabIds[nodeData.spaceId]; + if (focusedTabId !== undefined && nodeData.tabIds.includes(focusedTabId)) { + isActive = true; + } + } + } + + const focusedTab = focusedTabBySpaceId.get(nodeData.spaceId) ?? null; - const tabGroupKey = `${tabGroupData.spaceId}:${tabGroupData.id}`; - const previousEntry = previousTabGroupCache.get(tabGroupKey); + const layoutNodeKey = `${nodeData.spaceId}:${nodeData.id}`; + const previousEntry = previousLayoutNodeCache.get(layoutNodeKey); - let tabGroup: TabGroup; + let layoutNode: TabLayoutNodeView; if ( previousEntry && - previousEntry.source === tabGroupData && + previousEntry.source === nodeData.nodeData && previousEntry.active === isActive && previousEntry.focusedTab === focusedTab && areSameTabRefs(previousEntry.tabs, tabs) ) { - tabGroup = previousEntry.value; + layoutNode = previousEntry.value; } else { - tabGroup = { - ...tabGroupData, + layoutNode = { + id: nodeData.id, + mode: nodeData.mode, + profileId: nodeData.profileId, + spaceId: nodeData.spaceId, + position: nodeData.position, + tabIds: nodeData.tabIds, + frontTabId: nodeData.frontTabId, tabs, active: isActive, focusedTab }; } - nextTabGroupCache.set(tabGroupKey, { - source: tabGroupData, + nextLayoutNodeCache.set(layoutNodeKey, { + source: nodeData.nodeData, tabs, active: isActive, focusedTab, - value: tabGroup + value: layoutNode }); - tabGroups.push(tabGroup); + layoutNodes.push(layoutNode); - const existingGroups = tabGroupsBySpaceId.get(tabGroupData.spaceId); - if (existingGroups) { - existingGroups.push(tabGroup); + const existingNodes = layoutNodesBySpaceId.get(nodeData.spaceId); + if (existingNodes) { + existingNodes.push(layoutNode); } else { - tabGroupsBySpaceId.set(tabGroupData.spaceId, [tabGroup]); + layoutNodesBySpaceId.set(nodeData.spaceId, [layoutNode]); } - if (isActive && !activeTabGroupBySpaceId.has(tabGroupData.spaceId)) { - activeTabGroupBySpaceId.set(tabGroupData.spaceId, tabGroup); + if (isActive && !activeLayoutNodeBySpaceId.has(nodeData.spaceId)) { + activeLayoutNodeBySpaceId.set(nodeData.spaceId, layoutNode); } } - for (const [spaceId, spaceTabGroups] of tabGroupsBySpaceId) { - spaceTabGroups.sort((a, b) => a.position - b.position); - if (!activeTabGroupBySpaceId.has(spaceId)) { - activeTabGroupBySpaceId.set(spaceId, null); + for (const [spaceId, spaceLayoutNodes] of layoutNodesBySpaceId) { + spaceLayoutNodes.sort((a, b) => a.position - b.position); + if (!activeLayoutNodeBySpaceId.has(spaceId)) { + activeLayoutNodeBySpaceId.set(spaceId, null); } if (!focusedTabBySpaceId.has(spaceId)) { focusedTabBySpaceId.set(spaceId, null); @@ -315,30 +382,30 @@ export const TabsProvider = ({ children }: TabsProviderProps) => { } return { - tabGroups, - tabGroupsBySpaceId, - activeTabGroupBySpaceId, + layoutNodes, + layoutNodesBySpaceId, + activeLayoutNodeBySpaceId, focusedTabBySpaceId, - nextTabGroupCache + nextLayoutNodeCache }; }, [tabsData]); useEffect(() => { - tabGroupCacheRef.current = nextTabGroupCache; - }, [nextTabGroupCache]); + layoutNodeCacheRef.current = nextLayoutNodeCache; + }, [nextLayoutNodeCache]); - const getTabGroups = useCallback( + const getLayoutNodes = useCallback( (spaceId: string) => { - return tabGroupsBySpaceId.get(spaceId) ?? EMPTY_TAB_GROUPS; + return layoutNodesBySpaceId.get(spaceId) ?? EMPTY_LAYOUT_NODES; }, - [tabGroupsBySpaceId] + [layoutNodesBySpaceId] ); - const getActiveTabGroup = useCallback( + const getActiveLayoutNode = useCallback( (spaceId: string) => { - return activeTabGroupBySpaceId.get(spaceId) ?? null; + return activeLayoutNodeBySpaceId.get(spaceId) ?? null; }, - [activeTabGroupBySpaceId] + [activeLayoutNodeBySpaceId] ); const getFocusedTab = useCallback( @@ -348,10 +415,10 @@ export const TabsProvider = ({ children }: TabsProviderProps) => { [focusedTabBySpaceId] ); - const activeTabGroup = useMemo(() => { + const activeLayoutNode = useMemo(() => { if (!currentSpace) return null; - return getActiveTabGroup(currentSpace.id); - }, [getActiveTabGroup, currentSpace]); + return getActiveLayoutNode(currentSpace.id); + }, [getActiveLayoutNode, currentSpace]); const focusedTab = useMemo(() => { if (!currentSpace) return null; @@ -373,15 +440,15 @@ export const TabsProvider = ({ children }: TabsProviderProps) => { } }, [focusedTab]); - const groupsContextValue = useMemo( + const layoutNodesContextValue = useMemo( () => ({ - tabGroups, - getTabGroups, - getActiveTabGroup, + layoutNodes, + getLayoutNodes, + getActiveLayoutNode, getFocusedTab, - activeTabGroup + activeLayoutNode }), - [tabGroups, getTabGroups, getActiveTabGroup, getFocusedTab, activeTabGroup] + [layoutNodes, getLayoutNodes, getActiveLayoutNode, getFocusedTab, activeLayoutNode] ); const focusedContextValue = useMemo( @@ -399,19 +466,19 @@ export const TabsProvider = ({ children }: TabsProviderProps) => { const contextValue = useMemo( () => ({ - ...groupsContextValue, + ...layoutNodesContextValue, ...focusedContextValue, // Utilities // tabsData, getActiveTabId, getFocusedTabId }), - [groupsContextValue, focusedContextValue, tabsData, getActiveTabId, getFocusedTabId] + [layoutNodesContextValue, focusedContextValue, tabsData, getActiveTabId, getFocusedTabId] ); return ( - + @@ -421,7 +488,7 @@ export const TabsProvider = ({ children }: TabsProviderProps) => { - + ); }; diff --git a/src/renderer/src/components/settings/sections/general/update-card.tsx b/src/renderer/src/components/settings/sections/general/update-card.tsx index 490b518c1..abe6208ae 100644 --- a/src/renderer/src/components/settings/sections/general/update-card.tsx +++ b/src/renderer/src/components/settings/sections/general/update-card.tsx @@ -68,7 +68,7 @@ export function UpdateCard() { }, [checkForUpdates, updateStatus, isAutoUpdateSupported]); const openDownloadPage = () => { - flow.tabs.newTab(DOWNLOAD_PAGE, true); + flow.tabService.newTab(DOWNLOAD_PAGE, true); }; const handleInstallUpdate = async () => { diff --git a/src/renderer/src/lib/omnibox-new/suggestors/open-tabs.ts b/src/renderer/src/lib/omnibox-new/suggestors/open-tabs.ts index c24ee174e..c25f4a54c 100644 --- a/src/renderer/src/lib/omnibox-new/suggestors/open-tabs.ts +++ b/src/renderer/src/lib/omnibox-new/suggestors/open-tabs.ts @@ -1,7 +1,7 @@ import { generateTitleFromUrl, isValidUrl } from "../helpers"; import { getOmniboxCurrentSpaceId } from "../states"; import type { OpenTabSuggestion } from "../types"; -import type { TabData, WindowTabsData } from "~/types/tabs"; +import type { TabData, WindowTabsPayload } from "~/types/tab-service"; import { stringSimilarity } from "string-similarity-js"; const OPEN_TAB_LIMIT = 3; @@ -23,7 +23,7 @@ type NormalizedOpenTab = TabData & { type OpenTabsCacheEntry = { tabs: TabData[]; - focusedTabIds: WindowTabsData["focusedTabIds"]; + focusedTabIds: WindowTabsPayload["focusedTabIds"]; loadedAt: number; refreshPromise: Promise | null; }; @@ -117,7 +117,7 @@ function getEligibleOpenTabs(cacheEntry: OpenTabsCacheEntry, currentSpaceId: str const focusedTabId = cacheEntry.focusedTabIds[currentSpaceId] ?? null; return cacheEntry.tabs - .filter((tab) => tab.spaceId === currentSpaceId && !tab.ephemeral && tab.id !== focusedTabId) + .filter((tab) => tab.spaceId === currentSpaceId && tab.id !== focusedTabId) .map(normalizeOpenTab); } @@ -138,7 +138,7 @@ export function primeOpenTabsCache( return Promise.resolve(); } - const refreshPromise = flow.tabs + const refreshPromise = flow.tabService .getData() .then((tabsData) => { openTabsCache.set(currentSpaceId, { diff --git a/src/renderer/src/lib/omnibox/data-providers/open-tabs.ts b/src/renderer/src/lib/omnibox/data-providers/open-tabs.ts index 9ba4ea8f9..1dac142f7 100644 --- a/src/renderer/src/lib/omnibox/data-providers/open-tabs.ts +++ b/src/renderer/src/lib/omnibox/data-providers/open-tabs.ts @@ -2,7 +2,7 @@ export async function getOpenTabsInSpace() { const spaceId = await flow.spaces.getUsingSpace(); if (!spaceId) return []; - const tabsData = await flow.tabs.getData(); + const tabsData = await flow.tabService.getData(); const tabs = tabsData.tabs.filter((tab) => tab.spaceId === spaceId); return tabs; diff --git a/src/renderer/src/lib/omnibox/omnibox.ts b/src/renderer/src/lib/omnibox/omnibox.ts index 043d9f5bc..a2eb2386f 100644 --- a/src/renderer/src/lib/omnibox/omnibox.ts +++ b/src/renderer/src/lib/omnibox/omnibox.ts @@ -75,7 +75,7 @@ export class Omnibox { public openMatch(autocompleteMatch: AutocompleteMatch, whereToOpen: "current" | "new_tab"): void { if (autocompleteMatch.type === "open-tab") { const [, tabId] = autocompleteMatch.destinationUrl.split(":"); - flow.tabs.switchToTab(parseInt(tabId)); + flow.tabService.switchToTab(parseInt(tabId)); } else if (autocompleteMatch.type === "pedal") { const pedalAction = autocompleteMatch.destinationUrl; // Execute the pedal action @@ -86,16 +86,16 @@ export class Omnibox { } else if (pedalAction === "open_incognito_window") { flow.browser.createIncognitoWindow(); } else if (pedalAction === "open_extensions") { - flow.tabs.newTab("flow://extensions", true); + flow.tabService.newTab("flow://extensions", true); } else if (pedalAction === "open_history") { - flow.tabs.newTab("flow://history", true); + flow.tabService.newTab("flow://history", true); } } else { const url = autocompleteMatch.destinationUrl; if (whereToOpen === "current") { flow.navigation.goTo(url, undefined, true); } else { - flow.tabs.newTab(url, true, undefined, true); + flow.tabService.newTab(url, true, undefined, true); } } } diff --git a/src/renderer/src/routes/history/page.tsx b/src/renderer/src/routes/history/page.tsx index f6b4d59ad..27a794c6f 100644 --- a/src/renderer/src/routes/history/page.tsx +++ b/src/renderer/src/routes/history/page.tsx @@ -127,7 +127,7 @@ function HistoryPage() { const grouped = useMemo(() => groupVisitsByDay(visits), [visits]); const openInNewTab = (url: string) => { - void flow.tabs.newTab(url, true); + void flow.tabService.newTab(url, true); }; const copyLinkAddress = (url: string) => { diff --git a/src/shared/flow/flow.ts b/src/shared/flow/flow.ts index 694febd5a..9262a0f06 100644 --- a/src/shared/flow/flow.ts +++ b/src/shared/flow/flow.ts @@ -3,8 +3,7 @@ import { FlowWindowsAPI } from "~/flow/interfaces/app/windows"; import { FlowExtensionsAPI } from "~/flow/interfaces/app/extensions"; import { FlowBrowserAPI } from "~/flow/interfaces/browser/browser"; -import { FlowTabsAPI } from "~/flow/interfaces/browser/tabs"; -import { FlowPinnedTabsAPI } from "~/flow/interfaces/browser/pinned-tabs"; + import { FlowPageAPI } from "~/flow/interfaces/browser/page"; import { FlowNavigationAPI } from "~/flow/interfaces/browser/navigation"; import { FlowInterfaceAPI } from "~/flow/interfaces/browser/interface"; @@ -14,6 +13,7 @@ import { FlowFindInPageAPI } from "~/flow/interfaces/browser/find-in-page"; import { FlowHistoryAPI } from "~/flow/interfaces/browser/history"; import { FlowPasskeyAPI } from "~/flow/interfaces/browser/passkey"; import { FlowPromptsAPI } from "~/flow/interfaces/browser/prompts"; +import { FlowTabServiceAPI } from "~/flow/interfaces/browser/tab-service"; import { FlowProfilesAPI } from "~/flow/interfaces/sessions/profiles"; import { FlowSpacesAPI } from "~/flow/interfaces/sessions/spaces"; @@ -42,8 +42,6 @@ declare global { // Browser APIs browser: FlowBrowserAPI; - tabs: FlowTabsAPI; - pinnedTabs: FlowPinnedTabsAPI; page: FlowPageAPI; navigation: FlowNavigationAPI; history: FlowHistoryAPI; @@ -53,6 +51,7 @@ declare global { newTab: FlowNewTabAPI; findInPage: FlowFindInPageAPI; prompts: FlowPromptsAPI; + tabService: FlowTabServiceAPI; // Session APIs profiles: FlowProfilesAPI; diff --git a/src/shared/flow/interfaces/browser/pinned-tabs.ts b/src/shared/flow/interfaces/browser/pinned-tabs.ts deleted file mode 100644 index 7f2ff1c8d..000000000 --- a/src/shared/flow/interfaces/browser/pinned-tabs.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { IPCListener } from "~/flow/types"; -import { PinnedTabData } from "~/types/pinned-tabs"; - -// API // -export interface FlowPinnedTabsAPI { - /** - * Get all pinned tabs grouped by profile ID. - * @returns A record mapping profile IDs to arrays of pinned tab data - */ - getData: () => Promise>; - - /** - * Listen for changes to pinned tabs data. - * @param callback Receives all pinned tabs grouped by profile ID - */ - onChanged: IPCListener<[Record]>; - - /** - * Create a pinned tab from an existing browser tab. - * The tab's current URL becomes the pinned tab's defaultUrl. - * @param tabId The ID of the browser tab to pin - * @param position Optional position in the pin grid to insert at - */ - createFromTab: (tabId: number, position?: number) => Promise; - - /** - * Click handler: activate or create the associated browser tab. - * @param pinnedTabId The unique ID of the pinned tab - */ - click: (pinnedTabId: string) => Promise; - - /** - * Double-click handler: navigate associated tab back to defaultUrl. - * @param pinnedTabId The unique ID of the pinned tab - */ - doubleClick: (pinnedTabId: string) => Promise; - - /** - * Remove a pinned tab. - * @param pinnedTabId The unique ID of the pinned tab to remove - */ - remove: (pinnedTabId: string) => Promise; - - /** - * Unpin a tab back to the tab list. - * Removes the pinned tab and makes the associated browser tab persistent - * so it reappears in the sidebar. - * @param pinnedTabId The unique ID of the pinned tab to unpin - * @param position Optional position in the tab list to place the tab - */ - unpinToTabList: (pinnedTabId: string, position?: number) => Promise; - - /** - * Reorder a pinned tab to a new position. - * @param pinnedTabId The unique ID of the pinned tab - * @param newPosition The new position index - */ - reorder: (pinnedTabId: string, newPosition: number) => Promise; - - /** - * Show the context menu for a pinned tab. - * @param pinnedTabId The unique ID of the pinned tab - */ - showContextMenu: (pinnedTabId: string) => void; -} diff --git a/src/shared/flow/interfaces/browser/tab-service.ts b/src/shared/flow/interfaces/browser/tab-service.ts new file mode 100644 index 000000000..0bc1de2ba --- /dev/null +++ b/src/shared/flow/interfaces/browser/tab-service.ts @@ -0,0 +1,112 @@ +import { IPCListener } from "~/flow/types"; +import { + TabData, + TabLayoutNodeData, + WindowTabsPayload, + PinnedTabData, + RecentlyClosedTabData, + TabPlaceholderUpdate, + TabTargetUrlUpdate +} from "~/types/tab-service"; + +/** + * Flow Tab Service API — the renderer-facing interface for tab management. + * + * Replaces the old FlowTabsAPI and FlowPinnedTabsAPI with a unified, + * clean API surface. + */ +export interface FlowTabServiceAPI { + // --- Data Queries --- + + /** Get the full tabs payload for this window. */ + getData: () => Promise; + + /** Full data refresh (structural changes: tab created/removed, active changed). */ + onDataUpdated: IPCListener<[WindowTabsPayload]>; + + /** Lightweight content-only updates (title, url, isLoading, etc.). */ + onContentUpdated: IPCListener<[TabData[]]>; + + /** Tab-sync screenshot placeholder updates. */ + onPlaceholderChanged: IPCListener<[TabPlaceholderUpdate]>; + + /** Hover link target URL updates (Chrome-like status bar). */ + onTargetUrlChanged: IPCListener<[TabTargetUrlUpdate]>; + + // --- Tab Operations --- + + /** Switch to (activate) a tab by ID. */ + switchToTab: (tabId: number) => Promise; + + /** Create a new tab. */ + newTab: (url?: string, isForeground?: boolean, spaceId?: string, typedFromAddressBar?: boolean) => Promise; + + /** Close a tab by ID. */ + closeTab: (tabId: number) => Promise; + + /** Set muted state. */ + setTabMuted: (tabId: number, muted: boolean) => Promise; + + /** Move a tab to a new position. */ + moveTab: (tabId: number, newPosition: number) => Promise; + + /** Move a tab to a different space. */ + moveTabToSpace: (tabId: number, spaceId: string, newPosition?: number) => Promise; + + /** Batch move multiple tabs to a space. */ + batchMoveTabs: (tabIds: number[], spaceId: string, newPositionStart?: number) => Promise; + + /** Show context menu for a tab. */ + showContextMenu: (tabId: number) => void; + + /** Disable picture-in-picture. */ + disablePictureInPicture: (goBackToTab: boolean) => Promise; + + // --- Recently Closed --- + + /** Get all recently closed tabs. */ + getRecentlyClosed: () => Promise; + + /** Restore a recently closed tab. */ + restoreRecentlyClosed: (uniqueId: string) => Promise; + + /** Clear all recently closed tabs. */ + clearRecentlyClosed: () => Promise; + + // --- Layout Node Operations --- + + /** Create a multi-tab layout node (glance or split). */ + createLayoutNode: (mode: "glance" | "split", tabIds: number[]) => Promise; + + /** Dissolve a layout node back to individual tabs. */ + dissolveLayoutNode: (nodeId: string) => Promise; + + // --- Pinned Tabs --- + + /** Get all pinned tabs grouped by profile ID. */ + getPinnedTabs: () => Promise>; + + /** Listen for pinned tab changes. */ + onPinnedTabsChanged: IPCListener<[Record]>; + + /** Create a pinned tab from an existing browser tab. */ + createPinnedTabFromTab: (tabId: number, position?: number) => Promise; + + /** Click a pinned tab (activate or create associated tab). */ + clickPinnedTab: (pinnedTabId: string) => Promise; + + /** Double-click a pinned tab (navigate to default URL). */ + doubleClickPinnedTab: (pinnedTabId: string) => Promise; + + /** Remove a pinned tab. */ + removePinnedTab: (pinnedTabId: string) => Promise; + + /** Unpin back to tab list. */ + unpinToTabList: (pinnedTabId: string, position?: number) => Promise; + + /** Reorder a pinned tab. */ + reorderPinnedTab: (pinnedTabId: string, newPosition: number) => Promise; + + /** Show context menu for a pinned tab. */ + showPinnedTabContextMenu: (pinnedTabId: string) => void; +} diff --git a/src/shared/flow/interfaces/browser/tabs.ts b/src/shared/flow/interfaces/browser/tabs.ts deleted file mode 100644 index 19920a628..000000000 --- a/src/shared/flow/interfaces/browser/tabs.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { IPCListener } from "~/flow/types"; -import { RecentlyClosedTabData, TabData, TabPlaceholderUpdate, TabTargetUrlUpdate, WindowTabsData } from "~/types/tabs"; - -// API // -export interface FlowTabsAPI { - /** - * Get the data for all tabs - * @returns The data for all tabs - */ - getData: () => Promise; - - /** - * Add a callback to be called when the tabs data is updated (full refresh) - * @param callback The callback to be called when the tabs data is updated - */ - onDataUpdated: IPCListener<[WindowTabsData]>; - - /** - * Add a callback for lightweight content-only tab updates. - * Receives only the tabs whose content (title, url, isLoading, etc.) changed, - * without a full WindowTabsData refresh. - * @param callback Receives an array of updated TabData objects - */ - onTabsContentUpdated: IPCListener<[TabData[]]>; - - /** - * Add a callback for tab-sync screenshot placeholder updates. - * When tab sync is enabled and a tab's view moves to another window, - * the old window receives the snapshot ID of the tab's last screenshot. - * The renderer resolves it through `flow-internal://tab-snapshot?id=...`. - * `snapshotId: null` means clear the placeholder. - * `generation` is monotonic per window and lets the renderer ignore stale updates. - * @param callback Receives the placeholder payload - */ - onPlaceholderChanged: IPCListener<[TabPlaceholderUpdate]>; - - /** - * Hover link target URL updates for the active tab (Chrome-like status bar). - * `url` is empty when the cursor leaves a link or the tab is torn down. - */ - onTargetUrlChanged: IPCListener<[TabTargetUrlUpdate]>; - - /** - * Switch to a tab - * @param tabId The id of the tab to switch to - */ - switchToTab: (tabId: number) => Promise; - - /** - * Create a new tab - * @param url The url to load in the tab - * @param isForeground Whether to make the tab the foreground tab - * @param spaceId The id of the space to create the tab in - */ - newTab: (url?: string, isForeground?: boolean, spaceId?: string, typedFromAddressBar?: boolean) => Promise; - - /** - * Close a tab - * @param tabId The id of the tab to close - */ - closeTab: (tabId: number) => Promise; - - /** - * Show the context menu for a tab - * @param tabId The id of the tab to show the context menu for - */ - showContextMenu: (tabId: number) => void; - - /** - * Disable Picture in Picture mode for a tab - * @param goBackToTab Whether to go back to the tab after Picture in Picture mode is disabled - */ - disablePictureInPicture: (goBackToTab: boolean) => Promise; - - /** - * Set the muted state of a tab - * @param tabId The id of the tab to set muted state for - * @param muted Whether the tab should be muted - */ - setTabMuted: (tabId: number, muted: boolean) => Promise; - - /** - * Move a tab to a new position - * @param tabId The id of the tab to move - * @param newPosition The new position of the tab - */ - moveTab: (tabId: number, newPosition: number) => Promise; - - /** - * Move a tab to a new space - * @param tabId The id of the tab to move - * @param spaceId The id of the space to move the tab to - * @param newPosition The new position of the tab - */ - moveTabToWindowSpace: (tabId: number, spaceId: string, newPosition?: number) => Promise; - - /** - * Move multiple tabs to a new space in one operation - * @param tabIds The ids of the tabs to move - * @param spaceId The target space id - * @param newPositionStart The starting position for the moved tabs - */ - batchMoveTabs: (tabIds: number[], spaceId: string, newPositionStart?: number) => Promise; - - /** - * Get all recently closed tabs - * @returns Array of recently closed tab data, sorted by most recently closed first - */ - getRecentlyClosed: () => Promise; - - /** - * Restore a recently closed tab by its unique ID - * @param uniqueId The unique ID of the tab to restore - * @returns Whether the tab was successfully restored - */ - restoreRecentlyClosed: (uniqueId: string) => Promise; - - /** - * Clear all recently closed tabs - * @returns Whether the operation was successful - */ - clearRecentlyClosed: () => Promise; -} diff --git a/src/shared/types/tab-service.ts b/src/shared/types/tab-service.ts new file mode 100644 index 000000000..fe7ce3b0d --- /dev/null +++ b/src/shared/types/tab-service.ts @@ -0,0 +1,187 @@ +/** + * Tab Service v2 — Shared Types + * + * These types define the contract between main process (TabService) + * and renderer process (providers/IPCs). + */ + +export const TAB_SERVICE_SCHEMA_VERSION = 2; + +// --- Tab Types --- + +export type NavigationEntry = { + title: string; + url: string; + pageState?: string; +}; + +/** + * How a tab was opened / what it's linked to. + * - "normal": Standard tab with no special linkage. + * - "pinned": Tab is owned by a PinnedTab. Ephemeral (not persisted independently). + * - "bookmark": (Future) Tab is owned by a Bookmark. Ephemeral. + */ +export type TabOwnerKind = "normal" | "pinned" | "bookmark"; + +/** + * Reference to the entity that owns this tab (if not "normal"). + */ +export type TabOwnerRef = + | { kind: "normal" } + | { kind: "pinned"; pinnedTabId: string } + | { kind: "bookmark"; bookmarkId: string }; + +/** + * Persisted tab data saved to disk. + * Does NOT include transient runtime state. + */ +export type PersistedTabData = { + schemaVersion: number; + uniqueId: string; + createdAt: number; + lastActiveAt: number; + position: number; + + profileId: string; + spaceId: string; + windowGroupId: string; + + title: string; + url: string; + faviconURL: string | null; + muted: boolean; + + navHistory: NavigationEntry[]; + navHistoryIndex: number; + + owner: TabOwnerRef; +}; + +/** + * Runtime tab data sent to the renderer. + * Excludes navHistory (fetched on demand) and adds runtime fields. + */ +export type TabData = Omit & { + id: number; + windowId: number; + isLoading: boolean; + audible: boolean; + fullScreen: boolean; + isPictureInPicture: boolean; + asleep: boolean; +}; + +// --- Tab Layout Node Types --- + +/** + * A TabLayoutNode represents tabs that are displayed together. + * In the old system this was called a "TabGroup" with modes (glance, split). + * In the new system we explicitly define layout node types. + */ +export type TabLayoutNodeMode = "single" | "glance" | "split"; + +export type TabLayoutNodeData = { + id: string; + mode: TabLayoutNodeMode; + tabIds: number[]; + /** For glance mode: which tab is shown in front */ + frontTabId?: number; + position: number; + spaceId: string; + profileId: string; +}; + +/** + * Persisted tab layout node data. + */ +export type PersistedTabLayoutNodeData = { + id: string; + mode: Exclude; + tabUniqueIds: string[]; + frontTabUniqueId?: string; + position: number; + spaceId: string; + profileId: string; +}; + +// --- Tab Group Types (folder-like grouping) --- + +/** + * TabGroup is a logical folder-like grouping of tabs. + * This is NOT the same as the old TabGroup (which is now TabLayoutNode). + * TabGroups in v2 are for organizing tabs (like Chrome's tab groups with colors/labels). + * NOTE: This is a future feature placeholder. Not implemented yet. + */ +export type TabGroupData = { + id: string; + name: string; + color: string; + tabIds: number[]; + collapsed: boolean; + spaceId: string; + profileId: string; +}; + +// --- Pinned Tab Types --- + +export type PersistedPinnedTabData = { + uniqueId: string; + profileId: string; + defaultUrl: string; + faviconUrl: string | null; + position: number; +}; + +export type PinnedTabData = PersistedPinnedTabData & { + /** Runtime: map of spaceId -> associated tab ID */ + associatedTabIds: Record; +}; + +// --- Window State Types --- + +export type PersistedWindowState = { + width: number; + height: number; + x?: number; + y?: number; + isPopup?: boolean; +}; + +// --- Aggregate Data sent to Renderer --- + +export type WindowFocusedTabIds = { + [spaceId: string]: number; +}; + +export type WindowActiveLayoutNodeIds = { + [spaceId: string]: string; +}; + +export type WindowTabsPayload = { + tabs: TabData[]; + layoutNodes: TabLayoutNodeData[]; + focusedTabIds: WindowFocusedTabIds; + activeLayoutNodeIds: WindowActiveLayoutNodeIds; +}; + +// --- Recently Closed --- + +export type RecentlyClosedTabData = { + closedAt: number; + tabData: PersistedTabData; + layoutNodeData?: PersistedTabLayoutNodeData; +}; + +// --- Placeholder & Target URL (for tab sync) --- + +export type TabPlaceholderUpdate = { + snapshotId: string | null; + generation: number; + spaceId: string | null; +}; + +export type TabTargetUrlUpdate = { + tabId: number; + windowId: number; + url: string; +}; diff --git a/src/shared/types/tabs.ts b/src/shared/types/tabs.ts index f4c9fe449..ddabac9f9 100644 --- a/src/shared/types/tabs.ts +++ b/src/shared/types/tabs.ts @@ -14,9 +14,9 @@ export type NavigationEntry = { * Does NOT include transient runtime state (isLoading, audible, fullScreen, etc.) * or ephemeral IDs (webContents.id, runtime windowId). * - * To add a new persisted field: - * 1. Add it here - * 2. Update serializeTab() in saving/tabs/serialization.ts + * To add a new persisted field for Tab Service v2: + * 1. Add it to PersistedTabData in ~/types/tab-service.ts + * 2. Update TabPersistenceService.serializeTab() in services/tab-service/persistence/ */ export type PersistedTabData = { schemaVersion: number;