import './sidebarTree.component.scss'; import { CdkDragDrop } from '@angular/cdk/drag-drop'; import { AfterViewChecked, ElementRef, Injector, NgZone, OnDestroy, OnInit } from '@angular/core'; import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; import { AppService, ConfigService, HostAppService, HostWindowService, HotkeysService, NotificationsService, PartialProfile, PlatformService, PartialProfileGroup, Profile, ProfileGroup, ProfileProvider, ProfilesService, SplitDirection, TabsService } from 'tabby-core'; import { ForwardedPortConfig, SSHTabComponent } from 'tabby-ssh'; import { SidebarWorkspace } from '../configProvider'; import { SidebarPlusI18nService } from '../i18n'; import { SidebarPlusSnippetsService } from '../snippets.service'; import { SidebarPlusNoticesService } from '../notices.service'; import { SidebarPlusVisibilityService } from '../visibility.service'; import { SidebarPlusPingService } from '../ping.service'; import { PurgeLevel } from '../groupShare'; import { SidebarPlusTransfersService } from '../transfersRegistry.service'; interface CollapsableProfileGroup extends ProfileGroup { collapsed: boolean; children: PartialProfileGroup[]; } type ProfileConnectionStatus = 'connected' | 'error'; /** One row of the "Tunnels actifs" section — a single live port forward, tied back to the session carrying it. */ interface ActiveTunnel { tab: SSHTabComponent; /** Owning session's display name, so a tunnel can be read without cross-referencing the sessions list. */ sessionName: string; /** What the row shows: the user's own description when there is one, since that is what they named it for. */ label: string; /** Technical form (`L 127.0.0.1:8181 → localhost:8181`), revealed on click rather than shown by default. */ detail: string; /** Browsable address, for a Local forward only — Remote listens on the far end, Dynamic is a SOCKS proxy with no page to open. */ url: string | null; /** * `live` is a mounted forward. `waiting` is remembered from a session that * just died — the row stays, dimmed, while the session gets a chance to * come back (see TUNNEL_LOSS_GRACE_MS). `lost` is the aftermath of a * reconnection: a forward the revived session did *not* remount, which is * exactly what happens to a tunnel added on the fly — Tabby only remounts * what the profile carries (verified in `ssh.ts:493`, nothing else is * replayed). Shown for a while so the loss is seen instead of silent. */ state: 'live' | 'waiting' | 'lost'; /** The row's whole hover text, composed once — the detail, plus what the state means when it is not `live`. */ tooltip: string; /** `tunnelKey()` of the forward — never displayed, carried so memory can match rows across a reconnection. */ key: string; } /** One row of the "Sessions actives" section — a live SSH tab, flattened out of its split if it is in one. */ interface ActiveSession { tab: SSHTabComponent; /** The tab's manual rename if it has one, else the launching profile's name, else the tab's own title (quick connect, opened outside any saved profile). */ name: string; icon: string; color: string | null; /** The tab's live title (usually `user@host: cwd`), shown as a tooltip since it moves around too much to be the label. */ title: string; focused: boolean; /** * Number of the registry's `active` entries whose `sessionLabel` matches * this session's own `name` — see refreshSessionTransfers(). Zero means * nothing is moving on this session right now. */ transferCount: number; /** Combined speed of those entries, already formatted (shared formatSpeed()) — blank while count is 0 or no tick has produced a figure yet. */ transferSpeedLabel: string; /** * The row's whole compact segment ("3 ⇅ 2,4 Mo/s"), precomposed here * rather than concatenated in the template (piège #54) — blank when * transferCount is 0, in which case the row falls back to the uptime. */ transferLabel: string; } export declare class SidebarPlusTreeComponent implements OnInit, OnDestroy, AfterViewChecked { private config; private profilesService; private app; private tabsService; private notifications; private ngbModal; private zone; private platform; private hostApp; private hostWindow; private hotkeys; private ping; private visibility; private snippets; private notices; private profileProviders; private injector; private transfers; private i18n; /** Shown in the footer bar next to the plugin name. */ readonly pluginVersion: string; /** Footer links. Constants rather than template literals: the template must not compute (piège #54). */ readonly repositoryUrl = "https://github.com/TooMuhtsh/tabby-better-sidebar"; readonly authorUrl = "https://github.com/TooMuhtsh?tab=repositories"; profileGroups: PartialProfileGroup[]; /** What the template renders: `baseRootGroups`, narrowed to the open profiles while the "Actifs" tab is on — see setRootGroups(). */ rootGroups: PartialProfileGroup[]; /** The tree as built, before the "Actifs" narrowing. Never rendered as such while that tab is on. */ private baseRootGroups; /** * The "Actifs" tab (#8), on or off. A toggle laid over the selected * workspace rather than a workspace of its own: it narrows whatever is * showing to the profiles with an open tab, so on "Tous" — or with the * workspaces switched off — it shows every open session. * * Per machine, like the active workspace: what is open is a fact about * this window, not something to sync. */ activeOnly: boolean; /** The set of open profile ids the current narrowing was computed from — see refreshProfileStatuses(). */ private openProfileKey; filter: string; /** * The filter field itself, so the hotkey can put the cursor in it. * * Optional on purpose: it lives under `*ngIf='!sftpMode'`, so it is simply * absent while the sidebar shows the SFTP view. */ filterInput?: ElementRef; /** * Reference to the mounted SFTP panel, so openSessionSftp() can reach * past its own focus-follows-tab handshake and unfreeze a pin left * standing from an earlier navigation. Optional: absent whenever * `showSftp` is off, since the tag itself is `*ngIf`'d out then. */ private sftpPanel?; private hotkeySubscription; private visibilitySubscription; workspaces: SidebarWorkspace[]; activeWorkspaceId: any; contextMenuWorkspace: SidebarWorkspace | null; newWorkspaceName: string; renameWorkspaceValue: string; /** Toggles the sidebar between the normal tree and the "hidden items of this workspace" panel — see hiddenGroupsInWorkspace/hiddenProfilesInWorkspace. */ showHiddenPanel: boolean; /** * 'tabs' (the original `.workspace-bar` strip, wraps onto more rows as * workspaces pile up) or 'dropdown' (a single-line "active workspace + * chevron" trigger opening the full list as a popup) — an explicit * per-user setting (workspaceSelectorMode in configProvider.ts's * defaults, exposed in the settings tab next to showWorkspaces), not an * auto-detected one. An earlier pass here drove this off a * ResizeObserver instead and was rejected in testing (2026-08-07): it * picked the layout out from under the user mid-session, and its * right-click handling inside the dropdown popup — opening a *second* * `.group-context-menu`-classed popup as a side effect of the very click * that removed the first one, both matched by the same * `document.querySelector()` in clampContextMenuPosition() — turned out * unreliable (the same class of ambiguity as piège #30, just never * exercised by any earlier feature the way this one does). See * openWorkspaceMenuFromSwitcher() below for how list mode manages a * workspace instead (each row's "⋯" button, fixed anchor, list kept * open underneath — not a right-click). */ get workspaceSelectorMode(): 'tabs' | 'dropdown'; /** * Swaps the whole sidebar between the profile tree and the SFTP view of * the focused SSH session. Deliberately a *global* toggle for this first * pass rather than one state per tab: the roadmap leaves the question open * ("bascule par onglet ou globale ?"), and a single mode is the version * that can be judged in use before committing to per-tab bookkeeping. * * **Never restored across restarts**, by explicit user request * (2026-08-01): the sidebar always comes back on Profils. The SFTP view * only makes sense next to the session it was opened for, and that session * is gone after a restart — landing in it meant switching back by hand * every single time. The toggle stays session-local state, held in this * field alone. */ sftpMode: boolean; /** * Groups (isTemplate/blacklist already filtered, like profileGroups) but * *not* filtered by the active workspace's visibility — kept around so * the "show hidden" panel can still render a name/icon for something the * main tree currently excludes. Refreshed by every successful * loadTreeItems() call. */ private rawGroupsSnapshot; /** Monotonic counter guarding against overlapping loadTreeItems() calls (e.g. a config.changed$ reload racing an explicit workspace switch) — only the most recently *started* call's results are ever committed, see loadTreeItems(). */ private loadTreeRequestId; panelMinWidth: number; panelMaxWidth: number; panelInternalWidth: number; panelStartWidth: number; panelIsResizing: boolean; panelStartX: number; /** * The live SSH sessions listed at the top of the sidebar, rebuilt by the * same pass that refreshes the connection dots. Deliberately *not* * filtered by the active workspace: unlike visibility, favorites and * order, an open session is a fact about the app, not about the workspace * you happen to be looking at — hiding one you have open would be a way to * lose track of it. */ activeSessions: ActiveSession[]; /** tab → when it was first seen live, the only record of a session's age there is (see sessionUptime()). */ private sessionOpenedAt; /** The row under the pointer and the tooltip text held still for it — see sessionTooltip(). */ private hoveredSessionTab; private hoveredSessionTooltip; /** Per-machine UI state (localStorage, like sftpMode) rather than a `sidebarPlus.*` config key — nothing worth syncing across machines, and it sidesteps piège #16 entirely. */ activeSessionsCollapsed: boolean; /** * profileId → whether it currently carries an `active` upload/download, * precomputed rather than read from a template getter (piège #54): the * `.transfer-activity` badge is on every profile row, and a getter there * would rescan the whole registry on every change detection pass. * * Rebuilt by refreshTransferActivity(), never mutated in place. */ profileActivity: Map; /** * Session display name → the profile that launched it, built by * refreshActiveSessions() from the same live SSH tabs it already walks. * * The registry's entries carry a `sessionLabel` (G5's plain-field * principle: composed by whoever starts the transfer, the only party that * knows) but no profile id — nothing upstream of it needs one. This is the * join back to a profile row, by the one string both sides compute the * same way: `tab.customTitle || tab.topmostParent?.customTitle || * profile.name || tab.title`, mirrored in sftpPanel.component.ts's own * `sessionLabel` assignment. A tab renamed *after* a transfer started, or * one that closed before this map's next refresh, is the accepted gap — * the alternative was stamping a profile id at every registration site, * several of which live in sftpBrowser.component.ts (off limits here). */ private sessionLabelToProfileId; private transfersActivitySubscription; /** * Ids of the last profiles launched through launchProfile() — the single * point every launch path in this component funnels through — most * recent first, deduplicated, capped at MAX_RECENT_PROFILES. * * Persisted to `localStorage.sidebarPlusRecentProfiles`, deliberately * *not* `config.store.sidebarPlus`: this is per-machine usage, not * something a synced config.yaml should carry between machines — the * exact reasoning documented for `sidebarPlusActiveWorkspace` (switching * what you're looking at, here what you last launched, must not follow * you to another machine). See ROADMAP #historique-profils. * * Also deliberately not filtered by workspace, neither on write nor on * read: a launch is a fact of the app, the same reasoning already applied * to `activeSessions` above. */ private recentProfileIds; /** Per-machine UI state, same pattern as activeSessionsCollapsed. */ recentProfilesCollapsed: boolean; private static readonly MAX_RECENT_PROFILES; private static loadRecentProfileIds; /** * Live port forwards, flattened across every open SSH session, and the * per-profile count backing the badge in the tree. Both are rebuilt by the * same pass that refreshes the active sessions — Tabby emits nothing when a * forward is added or removed, so this rides the existing 2s poll rather * than introducing a second one. */ activeTunnels: ActiveTunnel[]; tunnelCounts: Map; /** * profileId → signatures of the forwards actually mounted on its live * session. Lets the config popup tell a tunnel Tabby is really running from * one merely written down — only the former resists deletion. */ private liveTunnelKeys; activeTunnelsCollapsed: boolean; profileStatuses: Map; private statusSubscription; private configSubscription; /** Focus moves *between panes* of the active split emit nothing on AppService — see watchSplitFocus(). */ private splitFocusSubscription; contextMenuGroup: PartialProfileGroup | null; contextMenuProfile: PartialProfile | null; contextMenuRoot: boolean; contextMenuX: number; contextMenuY: number; contextMenuMode: 'menu' | 'createGroup' | 'createProfile' | 'confirmDeleteProfile' | 'rename' | 'workspaceMenu' | 'createWorkspace' | 'renameWorkspace' | 'confirmDeleteWorkspace' | 'workspaceColor'; /** Set whenever a context menu/popup opens or switches mode — checked once in ngAfterViewChecked() to clamp it back on-screen after Angular renders it at its real size. */ private menuPositionDirty; /** Which submenu is open, or none — opening either replaces the other, so a single field is enough. */ activeSubmenu: 'manage' | 'more' | null; submenuX: number; submenuY: number; private submenuAnchorLeft; private submenuAnchorRight; private submenuAnchorTop; /** Set whenever the submenu opens or moves — checked once in ngAfterViewChecked(), same pattern as menuPositionDirty above and for the same reason (the DOM must have rendered the panel at its real size first). */ private submenuPositionDirty; /** * Debounces the submenu's close-on-mouseleave so the diagonal path from * the carrying entry to the panel beside it doesn't trip it — the pointer * briefly leaves the entry before it reaches the panel. Cancelled by * cancelSubmenuClose() if the pointer lands back on the entry or on the * panel itself before it fires. Deliberately simple (a flat delay, no * triangle-of-tolerance geometry): the user asked to keep this part * simple, and ~150-200ms already covers the normal case. */ private submenuCloseTimer; workspaceSwitcherOpen: boolean; workspaceSwitcherX: number; workspaceSwitcherY: number; newGroupName: string; renameValue: string; profileTemplates: { provider: ProfileProvider; template: PartialProfile; }[]; get isMenuMode(): boolean; get isCreateGroupMode(): boolean; get isCreateProfileMode(): boolean; get isConfirmDeleteProfileMode(): boolean; get isRenameMode(): boolean; get isWorkspaceMenuMode(): boolean; get isCreateWorkspaceMode(): boolean; get isRenameWorkspaceMode(): boolean; get isConfirmDeleteWorkspaceMode(): boolean; get isWorkspaceColorMode(): boolean; constructor(config: ConfigService, profilesService: ProfilesService, app: AppService, tabsService: TabsService, notifications: NotificationsService, ngbModal: NgbModal, zone: NgZone, platform: PlatformService, hostApp: HostAppService, hostWindow: HostWindowService, hotkeys: HotkeysService, ping: SidebarPlusPingService, visibility: SidebarPlusVisibilityService, snippets: SidebarPlusSnippetsService, notices: SidebarPlusNoticesService, profileProviders: ProfileProvider[], injector: Injector, transfers: SidebarPlusTransfersService, i18n: SidebarPlusI18nService); /** * Translates a `{ message, params }` handed back by a module with no * injector access of its own — `profileModal.ts`, `groupShare.ts`, * `workspaceShare.ts`, `svgSanitizer.ts` — at the one place that holds * `this.i18n`. */ private tMsg; /** * `describePurge()`'s clauses, translated and joined — the sentence that * function used to build itself before it lost injector access. Empty * string when nothing was taken out, same as before. */ private describePurgeText; ngOnInit(): Promise; ngOnDestroy(): void; ngAfterViewChecked(): void; /** Keeps whichever context menu/popup is currently open fully within the viewport — a right-click near the bottom/right edge of a tall sidebar would otherwise render partially under the taskbar or off-screen and be unusable. */ private clampContextMenuPosition; /** * Positions the open lateral submenu beside the entry that carries it — * flipped to the entry's LEFT when there is no room to its right — then * runs the result through clampInViewport(), the plugin's one point for * pulling a floating element back on screen (piège #30). Same deferred * pattern as clampContextMenuPosition() and for the same reason: the * panel's real width is only known once Angular has actually rendered it. */ private clampSubmenuPosition; /** * Returns whether this call's results were actually applied to * this.profileGroups/this.rootGroups. False means a newer loadTreeItems() * call was started (by config.changed$ or an explicit workspace switch) * before this one finished, and its results were discarded in favor of * that newer call's — see the requestId guard at the end. */ private loadTreeItems; /** * Computes, from the *unfiltered* raw group list, the cascaded set of * hidden group ids (a hidden parent hides its descendants too, for * rendering — buildGroupTree() would otherwise silently drop orphaned * children rather than promoting them) and the hidden profile ids. Pure * function of its arguments so loadTreeItems() can call it safely * regardless of what this.activeWorkspace might have changed to by the * time an overlapping call resumes. */ private static computeWorkspaceFilterState; launchProfile

(profile: PartialProfile

): Promise; /** * Pushes a launch onto the MRU trail — gated on showRecentProfiles so * that switching the block off stops the recording, not just the * display (the project's "un interrupteur de bloc coupe la source" rule). * Silently does nothing for a profile with no id (a template preview, * never actually reachable through launchProfile(), but nothing here * should assume it). */ private recordRecentProfile; /** * Resolves the MRU ids against the same in-memory profile list the tree * itself is built from — `rawGroupsSnapshot`, the workspace-*unfiltered* * snapshot `loadTreeItems()` refreshes on every reload — rather than * `config.store.profiles`, which omits provider-supplied profiles * entirely (piège #74: testing existence there would silently drop a * still-live entry, not just fail to show a dead one). * * An id that fails to resolve is skipped here, never removed from * `recentProfileIds`: unlike a deleted profile (which forgetDeletedId() * actively forgets from every workspace's state), a provider-supplied * profile can be resolvable again the next time its provider lists it, * so dropping it from the trail on one miss would be premature. * * Not filtered by the active workspace, on purpose — same "fact of the * app" reasoning as `activeSessions`, see ROADMAP #historique-profils. */ get recentProfiles(): PartialProfile[]; toggleRecentProfiles(): void; /** The row's whole click target — a relaunch, nothing else (no context menu, no favorite, no status: a pure re-engagement shortcut, per ROADMAP #historique-profils). */ launchRecentProfile(profile: PartialProfile, event: MouseEvent): void; /** * Tints a launch with the active workspace's color — on a *clone*, never * the live profile (piège #12: a mutation of the profile the tree still * renders is exactly what corrupted the user's config.yaml once). * * No new coloring mechanism: `ProfilesService.newTabParametersForProfile()` * (tabby-core, services/profiles.service.ts) already reads * `fullProfile.color` and forwards it as the new tab's `color` input — * the same field `profile-icon` colors its glyph with and * `TabHeaderComponent` draws a `.colorbar` from. `TAB_COLORS` * (tabby-core/utils.ts) is the very palette this plugin's workspace color * picker offers, so a workspace-tinted session looks identical to one * colored by hand from a tab's own native menu. This only decides which * value reaches that existing field. * * "Tous" never colors anything — `activeWorkspace` is already null there * — and a profile's own explicit color always wins: the workspace only * fills in what the profile left blank, it never overrides a choice * already made on the profile itself. */ private withWorkspaceColor; /** * Says, when a session opens, that snippets of this profile are waiting on * a value. * * The one moment worth saying it: a snippet edited in the settings tab can * introduce a placeholder that every profile already attached to it now * needs, and nothing else would surface that until someone clicked the * snippet and got a refusal. Here it arrives while the user is looking at * the profile. * * Keyed on the *set of missing names*, not on the profile: the same profile * has to speak up again when a snippet changes what it asks for, and stay * quiet when nothing has changed. That is also what keeps a folder-wide * launch from stacking one toast per session. */ private noticeUnansweredVariables; /** What noticeUnansweredVariables() has already said this session — see there for why the key is the missing set and not the profile. */ private readonly variableNoticesShown; launchProfileFromMenu(profile: PartialProfile): Promise; /** * Launches the group's direct profiles, each in its own tab. * * No recursion into sub-groups, no split panes, no synced multi-input — * and none of the three is a gap left for later. They were the whole of * the "Group Exec" roadmap item, and all three were declined on 2026-08-03 * once put as a question: recursion because forty sessions leave as easily * as four, splits because they only read well on the few servers one would * not have targeted a whole folder for, and broadcast because typing into * twelve production machines must not be a side effect of "open * everything". Tabby's own multi-input remains reachable by its hotkeys, * which is where it belongs. */ launchGroupSessions(group: PartialProfileGroup): Promise; /** * Puts the cursor in the filter field, selecting whatever is already in it * so a second search overwrites the first instead of appending to it. * * Switches back to the profile tree first when the sidebar is showing SFTP: * the field does not exist in that view, and doing nothing at all would * make the hotkey look broken. The focus is deferred one turn because the * field is only rendered on the change detection pass that follows. */ focusFilter(): void; /** * Whether the tree currently shows search results rather than itself. * * Drag and drop is switched off while it does, and that is a fix, not a * restriction: the results are a flat rearrangement of the real tree, so * dropping inside them writes an order nobody asked for. On "Tous" it goes * straight to the profiles' native `weight` — the search order becoming the * real order — and inside a workspace it files a `profileOrder` under the * fake `search` group id, which nothing ever reads again. * * The "Actifs" tab counts as one: its tree is a narrowed copy too, with * folders forced open, and a drop into it would file the same kind of * order computed from a partial list. */ get filtering(): boolean; /** * The result row the arrows point at. Two ids rather than the profile * alone: a profile can show up twice in the results, inside a folder whose * name matched and in the flat list, and it is the row that is lit. */ keyboardGroupId: string | null; keyboardProfileId: string | null; get searchSplitSide(): SplitDirection; get clearSearchOnLaunch(): boolean; /** * Arrows and Enter in the filter field, while it holds a search: the * keyboard path of Tabby's own profile selector, which the field already * stands in for. Escape keeps its own binding in the template. * * Ctrl/Alt/Meta+Enter are left alone, on purpose: Ctrl+Enter is a hotkey of * this plugin, and the others mean nothing here yet. */ onFilterKeydown(event: KeyboardEvent): void; /** * The profile rows of the results, top to bottom as rendered: a folder's * own profiles first, then its sub-folders, nothing inside a folder shown * shut. Walked on demand, on a key press, rather than kept in step with * every way the tree can change under a search. */ private visibleResultRows; private highlightedRowIndex; private setKeyboardHighlight; /** First result lit by default, like the native selector; nothing lit without a search. */ private resetKeyboardHighlight; /** Stops at both ends rather than wrapping, as the native selector does. A row folded away meanwhile restarts from the top. */ private moveKeyboardHighlight; /** * Opens the lit result: in a new tab, or with Shift in a new pane of the * active tab (side from `searchSplitSide`). * * The search stays by default, to open several connections in a row; the * field takes the focus back from the new terminal for that. With * `searchClearOnLaunch` the filter goes and the new session keeps it. */ private launchHighlighted; /** * The profile in a new pane of the active tab, next to its focused pane. * What Tabby does itself for "split", with a profile in place of the * duplicate: `tabsService.create()` on the profile's tab parameters, then * `addTab()` on the split (splitTab.component.ts, checked on 1.0.238). * * No tab open, or a tab that is not a split (the settings), falls back to a * plain new tab rather than doing nothing. */ private launchProfileInSplit; /** `Échap` in the field: drop the filter and hand the focus back, rather than leave a filter nobody can see the cause of. */ clearFilter(): void; onFilterChange(): Promise; /** * Folders whose own name matches, rendered above the profile results with * their contents — searching for a folder and being shown only the profiles * inside it was the gap here. * * Built from `rawGroupsSnapshot` for the same reason the profile search * uses the full profile list: a workspace hides things from the tree, it * does not make them unfindable. Collapsed state is forced open, since a * folder returned by a search that renders shut says nothing at all. */ private matchingGroups; get activeWorkspace(): SidebarWorkspace | null; selectWorkspace(id: string): void; /** Single re-entry point after anything that changes what should be visible (workspace switch, config change) — re-derives rootGroups from scratch, honoring an in-progress text filter if there is one. */ private refreshTree; /** * Opens the Renommer/Icône/Couleur/Copier/Supprimer menu for one * workspace, anchored at `event.clientX/Y`. One call site in the .pug: a * tab's own `(contextmenu)` in tabs mode, where at-the-cursor is the OS * convention. List mode goes through openWorkspaceMenuFromSwitcher() * below instead — same menu, fixed anchor. * * List mode does *not* wire this to a row's `(contextmenu)` either — * tried and reported unreliable in testing (2026-08-07), reverted * without a live repro to confirm the exact mechanism. The prime * suspect: the row that received the `contextmenu` event sat inside the * switcher popup, which (as a contextMenuMode value at the time) was * torn down by the very mutation its own handler made; the incoming * workspaceMenu popup shares the plain `.group-context-menu` class that * `document.querySelector()` in clampContextMenuPosition() matches on. * Same *kind* of outgoing-vs-incoming ambiguity as piège #30. The * switcher no longer being a mode removes the teardown half of that * race, but the right-click stays unwired: the "⋯" button is the one * documented entry, and it is a plain click with nothing to race. */ onWorkspaceTabContextMenu(event: MouseEvent, workspace: SidebarWorkspace): void; openCreateWorkspacePrompt(event: MouseEvent): void; /** * Toggles the workspace list popup that stands in for `.workspace-bar` * in 'dropdown' mode (see workspaceSelectorMode). Anchored to the compact * bar itself, never to the click position: the popup replaces the tab * strip, so it belongs under the control that opened it, and reopening it * must land in the same place every time (user request, 2026-08-07 — * fixed positions, not wherever the mouse happened to be). * * Left-click on an entry calls selectWorkspace(), which closes the popup * as its terminal action. Managing a workspace (Renommer/Icône/Couleur/ * Copier/Supprimer) goes through each row's own "⋯" button, wired to * openWorkspaceMenuFromSwitcher() — which opens NEXT to this popup and * leaves it on screen underneath. */ openWorkspaceSwitcher(event: MouseEvent): void; /** * Where popups spawned FROM the switcher anchor: flush against its right * edge, level with its top. One fixed spot for every "⋯" row and for * "Nouveau workspace...", so a manage flow reads as a stable second * column next to the list instead of hopping to wherever each click * landed. The sub-popups a manage flow then opens (rename, colour, * delete…) never touch contextMenuX/Y, so they inherit the same spot. * Falls back to the switcher's own anchor if the popup is somehow not in * the DOM; ngAfterViewChecked's clamp pulls it back on-screen when the * right edge has no room. */ private switcherPopupAnchor; /** The switcher-side twin of onWorkspaceTabContextMenu(): same menu, fixed anchor beside the list, list kept open underneath. */ openWorkspaceMenuFromSwitcher(event: MouseEvent, workspace: SidebarWorkspace): void; /** The switcher-side twin of openCreateWorkspacePrompt(), same fixed anchor as the manage menu. */ openCreateWorkspaceFromSwitcher(event: MouseEvent): void; createWorkspace(): Promise; /** * "Importer du presse-papiers", from the workspace creation popup. * * Nothing here trusts the payload — same discipline as `pasteGroup()`: * the JSON is clipboard text, hand-editable, and only checked on the way * in (`parseWorkspacePayload()`). The id is always minted fresh * (`generateWorkspaceId()`), never the one the payload carries, even when * it looks clean — reusing an id copied from another machine risks * colliding with one already in use locally. * * A name collision is silently suffixed rather than asked about, unlike a * folder paste: a workspace has no contents for "merge" to mean anything * against, it is a name plus a handful of exclusion/order lists, so there * is nothing to choose between beyond the name itself. * * Deliberately does not select the imported workspace — it lands in the * tab bar (via `refreshTree()`, which re-reads `this.workspaces` from * config without touching `activeWorkspaceId`) and waits to be clicked, * same as any workspace created by hand. */ importWorkspaceFromClipboard(): Promise; openRenameWorkspacePrompt(): void; confirmRenameWorkspace(): Promise; confirmDeleteWorkspacePrompt(): void; deleteWorkspace(): Promise; /** * "Copier (JSON)", from a workspace tab's context menu. * * Exports everything the workspace carries except its id — an id is * always minted fresh on import (`generateWorkspaceId()`), exactly like a * pasted folder's own id. No purge level to choose between, unlike * `copyGroupStructure()`: a workspace holds only ids (an exclusion list * and a couple of order maps), never a profile's `options`, so there is * nothing secret in it to begin with. */ copyWorkspace(): Promise; /** Single-click hide, no picker — always targets the active workspace. Meaningless (and hidden in the template) on "Tous", which never hides anything. */ hideInActiveWorkspace(): Promise; /** * Groups/profiles *directly* hidden in the active workspace (not * cascade-hidden descendants of a hidden parent) — backs the "show * hidden" panel next to the filter bar. Reads rawGroupsSnapshot (not * profileGroups) since these items are, by definition, excluded from * the normal filtered tree. */ get hiddenGroupsInWorkspace(): PartialProfileGroup[]; get hiddenProfilesInWorkspace(): PartialProfile[]; restoreGroupInWorkspace(group: PartialProfileGroup): Promise; restoreProfileInWorkspace(profile: PartialProfile): Promise; /** * Whether each block of the sidebar is on. * * Two independent reasons a block can be off, deliberately combined here * rather than at each call site: the user unticked it, or the host no * longer carries it (`hostSupports`, see hostCompat.ts). Callers only ever * want the conjunction, and splitting it would guarantee that one of the * twenty-odd usages eventually forgets the second half. * * These are getters, not fields: `config.store` is the single source of * truth and `config.changed$` already rebuilds the view, so caching them * would only add a way for the two to disagree. They are read from the * template on every change detection pass, which is fine — each is a * property lookup, not the method call of piège #54. */ get showActiveSessions(): boolean; /** * No `hostSupports(...)` gate, unlike its neighbours: a relaunch is a * generic profile action (SSH, local, whatever a provider offers), not * tied to the SSH tab or the SFTP panel the other blocks depend on. */ get showRecentProfiles(): boolean; get showTunnels(): boolean; get showSftp(): boolean; /** * The transfers panel, which belongs to the SFTP view and is switched off * with it. * * Subordinate rather than independent because that is where transfers come * from in practice, and because the settings page groups an option with the * feature it belongs to. The nuance to keep in mind: the registry also * mirrors transfers *this plugin did not start* — the native SFTP panel's, * another plugin's — and those become invisible here when the SFTP view is * off. That is why switching it off also stops hiding Tabby's own transfers * menu (see SidebarPlusMountService): the plugin must not hide the host's * only remaining readout of something it no longer shows itself. */ get showTransfers(): boolean; get showWorkspaces(): boolean; get showFilter(): boolean; get showSnippets(): boolean; get showNotes(): boolean; /** * Puts the sidebar back into a coherent state after a block is switched * off, so that nothing keeps acting through a control that is gone. * * Called from the `config.changed$` path. Each case is a state that would * otherwise be unreachable *and* unexplainable: a workspace still filtering * the tree with no tab bar to switch away from it, a filter still hiding * rows with no field showing why, an SFTP view with no way back to the * profiles. */ private reconcileHiddenBlocks; setSftpMode(on: boolean): void; startResize(event: MouseEvent): void; private draggedGroupId; /** Folder whose row the pointer is over, or null — only meaningful while draggedGroupId is set. */ private hoveredGroupId; /** Same pair for profile rows, driving multi-selection reorder placement — see updateHoveredProfile(). */ private draggedProfileId; private hoveredProfileId; /** * Folder whose own profile list the dragged profile is currently over — * feeds isNestTarget() below, kept apart from hoveredProfileId (which * drives *where in the list* it lands, not *which folder*). Set from * CDK's own (cdkDropListEntered)/(cdkDropListExited) on that list rather * than a live hit-test: unlike the folder-on-folder case there is no * upper/lower-half ambiguity to rescue here, CDK already resolves this * container correctly. */ private hoveredProfileTargetGroupId; /** The folder the dragged profile started in, captured once at drag start — tells a genuine cross into another folder apart from re-entering its own list. */ private draggedProfileSourceGroupId; onGroupDragStarted(group: PartialProfileGroup): void; /** * Only stops the tracking — `hoveredGroupId` is deliberately left set. * CDK emits `cdkDragEnded` *before* `cdkDropListDropped`, so clearing it * here would wipe the value a fraction of a millisecond before * onGroupDrop() reads it, and the rescue would never once fire. * onGroupDragStarted() resets it instead. */ onGroupDragEnded(): void; /** * Hit-tests the folder rows directly rather than going through * `document.elementFromPoint`: the drop-zone overlays defined in the scss * cover the lower half of a row and would answer instead of the row * itself, and they are exactly what is unreliable here. */ private updateHoveredGroup; onMouseMove(event: MouseEvent): void; stopResize(): boolean; /** * Hidden by the toggle hotkey — taken off the screen, not unmounted. The * attribute alone would lose to the tag's own `display: flex`, hence the * matching `[hidden]` rule in the stylesheet. */ get hiddenAttr(): '' | null; /** * Long names wrap onto a second line instead of ending in an ellipsis * (#10). A class on the host rather than `.no-wrap` taken off each row: * the stylesheet decides which names wrap (`.item-name`), and the rows * keep one template whichever way the setting points. */ get wrapNames(): boolean; /** * Room for the macOS traffic lights (#13). With the "thin" frame the * window has no title bar, and the three window buttons are drawn over its * top left corner, which is where this sidebar sits: they covered its * first row of buttons. * * The same condition Tabby uses to give its own profile tree the * `mac-inset` strip (appRoot.component.pug, checked on 1.0.238), so the * two agree on when the lights are there. Fullscreen hides them, and the * other frames draw a real title bar above the whole window. */ get macInset(): boolean; get panelWidth(): number; set panelWidth(value: number); toggleGroupCollapse(group: PartialProfileGroup): void; /** `localStorage.sidebarPlusGroupCollapsed`: group id → folded. Per machine and global, not per workspace. */ private static readCollapsedTable; /** * Whether the "collapse all" button collapses (true) or expands (false). * * The root level is enough to answer "is any folder still open on * screen?": an open folder is only visible when every ancestor is open, * root included. Read on every change detection pass, hence no deeper walk * (piège #54). Folders only — the three blocks above follow along but do * not decide. */ get anyGroupExpanded(): boolean; /** * Folds every folder at every depth, plus the active sessions, recent * profiles and tunnels blocks — or unfolds all of it when nothing is open * any more. * * Covers the folders a workspace currently hides as well * (`rawGroupsSnapshot`): the fold state is global, so "everything" means * the same thing whichever workspace is showing. One write of the group * table; each block keeps its own key, written as its own toggle does. * Never while a search is showing: the results force their folders open * and are not the tree. */ toggleCollapseAll(): void; toggleCollapseAllFromMenu(): void; private static intoCollapsable; private static groupIdFromContainerId; /** * Group ids that name a view, not a folder. * * Their rows live in real folders elsewhere — "Épinglés" gathers pinned * profiles from anywhere, the search group flattens the whole tree — so * their displayed order describes nothing that can be written back. * `ungrouped` is deliberately absent: it really is where profiles with no * group live, and its order is theirs. */ private static readonly SYNTHETIC_GROUP_IDS; private static isSyntheticGroupId; /** `groups-` → that group's id, `groups-root` → null (the shape persistGroupOrder() expects for the root level). */ private static parentGroupIdFromGroupContainerId; isFavorite(profile: PartialProfile): boolean; toggleFavorite(profile: PartialProfile, event: Event): void; toggleFavoriteFromMenu(profile: PartialProfile, event: Event): void; private get favoriteIds(); isFavoriteGroup(group: PartialProfileGroup): boolean; toggleFavoriteGroupFromMenu(group: PartialProfileGroup, event: Event): void; private get favoriteGroupIds(); /** * `workspace`/`sourceProfileGroups` default to live instance state for * the synchronous, non-racy call sites (toggleFavorite*, onFilterChange). * loadTreeItems() passes them explicitly instead — it computes against a * local snapshot taken before its own await, per the requestId-guard * discipline described there, and must not fall back to `this.*` here. */ private applyFavorites; getProfileStatus(profile: PartialProfile): ProfileConnectionStatus | null; private refreshProfileStatuses; get showActiveToggle(): boolean; /** Whether the tree is narrowed to the open profiles right now. The block switch suspends the tab without forgetting it was on. */ get narrowedToOpen(): boolean; toggleActiveOnly(): void; /** * The one place `rootGroups` is written. Every path that builds a tree — * the full load, the search, the search being cleared, a favorite toggled * — hands its result here, so the "Actifs" narrowing cannot be skipped by * one of them and later re-applied by another. */ private setRootGroups; /** * A copy of the tree holding only the profiles with an open tab, and the * folders on the way to them. * * Shallow copies, never the nodes themselves: those are `profileGroups`' * own objects, which the unnarrowed tree is rebuilt from. Folders come out * open — a matching profile inside a folder shown shut would be found and * still not seen, the same reason the search forces its folders open. */ private narrowToOpen; /** * Rebuilds the list of live SSH sessions, one row per *pane* — a split * holding three sessions is three rows, since focusing "the tab" would be * ambiguous otherwise. * * Only SSH tabs are listed, per the roadmap. Widening this to local/serial * tabs later is a matter of relaxing the `instanceof` and reading liveness * off `session` instead of `sshSession`. */ private refreshActiveSessions; /** * Recomputes each active session's transfer aggregate: how many of the * registry's `active` entries carry this session's own name as their * `sessionLabel`, and their combined speed — G5's plain-string join * (`entry.sessionLabel === session.name`), no profile id involved. * * Mutates the existing `ActiveSession` objects **in place** rather than * going through `this.activeSessions = …`: that reassignment is gated by * `sameSessions()` precisely to keep the array's identity stable across * the 2s poll, so that `*ngFor` does not tear down and rebuild every row * — which would drop the `:hover` state `.actions` lives in (see the * comment above `sameSessions()`). A transfer's speed moves on the * registry's own 500 ms tick, far more often than that guard would * tolerate if these fields were part of what it compares; mutating the * fields on the objects that are already there sidesteps the question * entirely, the same way the registry itself mutates `TransferEntry` * fields in place instead of replacing `entries`. * * Called from refreshActiveSessions() (2s cadence — plenty for ambient * reading, deliberately not the registry's own 500 ms tick) and from the * registry's `changed` event, so a transfer starting or ending shows up * without waiting up to 2s. The speed shown here can therefore trail the * transfers panel's own figure by up to 2s — accepted, and worth saying * here rather than leaving it to look like a bug: this is a row glanced * at in passing, not the panel itself. */ private refreshSessionTransfers; /** * The colour of the row's one dot. * * There is no second dot for latency, on purpose: every row in this section * is a live session by construction, so a dot that was always green said * nothing at all. With the probe off it stays green — the state it has * always shown — and with it on, it says how fast the session answers. */ sessionDotClass(session: ActiveSession): string; /** * The row's tooltip, carried by the whole row rather than by the dot: a 6px * dot is not something one aims at to read a figure (user request, * 2026-08-03). * * Form settled by the user the same day: `app.exemple.fr | 13 ms | 1m 47s`, * three fields and no prose. The tab's live title (`user@host: cwd`), which * this tooltip used to carry, is dropped with it — the row already shows * the name, and a title that moves with the working directory was the * verbose part. The latency field disappears entirely when there is no * figure, rather than showing a placeholder. */ sessionTooltip(session: ActiveSession): string; /** * Takes the snapshot the tooltip will show, and drops it on the way out. * * Consequence to accept: hover a row for two minutes and the figures are * those of the moment the pointer arrived. Leaving and coming back is what * refreshes them — and that is the gesture anyone makes to re-read a * tooltip anyway. */ onSessionHover(session: ActiveSession, hovering: boolean): void; private buildSessionTooltip; /** * How long the session has been up, stamped by this component the first * time the tab shows up in the list. * * Nothing in Tabby or `tabby-ssh` records when a session opened — checked * on the installed source — so it has to be observed. Two consequences, * both benign: a session that was already open when this component mounted * is dated from the mount (only reachable by disabling and re-enabling the * plugin, the sidebar being mounted at startup), and a session that drops * and reconnects restarts from zero, since it leaves the list in between. * The second one is arguably the right answer anyway. */ sessionUptime(session: ActiveSession): string; /** Milliseconds since the tab was first seen live, or null while it has not been stamped yet. */ private uptimeMs; /** * `1m 47s`, `3h 05m`, `2j 04h` — the tooltip's own form, precise to the * second under a minute. * * Deliberately not the same rendering as the row's: this one is only read * while hovering, whereas the row is permanently in view, and seconds * ticking on every open session would keep the sidebar moving in the * corner of the eye for no gain. */ private formatUptimePrecise; /** `42 s`, `12 min`, `3 h 05`, `2 j 4 h` — coarser as it gets longer, since a session's age is read at a glance. */ private formatUptime; private static sameSessions; /** The pane the user is actually looking at: `app.activeTab` is the split, not the session inside it. */ private resolveFocusedTab; /** * Keeps the focused-row highlight in step with focus moves *inside* the * active split, which AppService knows nothing about. Re-subscribed on * every refresh: dropping the old subscription unconditionally is what * makes switching from one split to another actually follow the new one. */ private watchSplitFocus; /** Whether this configured tunnel is one Tabby currently has mounted — the only kind whose deletion is withheld. */ isTunnelLive(forward: ForwardedPortConfig, profileId: string | undefined): boolean; /** * A tunnel row's tooltip, headed by its full label when that label is the * user's description: `.no-wrap` may cut it short on the row, and the rest * of the tooltip only gives the technical form. A row without description * already shows that form as its label, so nothing is repeated. Derived * from `label`/`detail`/`state` alone, which is why sameTunnels() can * leave it out. */ private static tunnelTooltip; private static sameTunnels; /** * What each SSH tab's tunnels looked like when last seen alive, kept across * the death of the session so a micro-cut shows as a state instead of a * disappearance. Pruned when the tab itself goes away. */ private tunnelMemory; /** Forwards already dismounted as duplicates — the poll must not notice (or dismount) them twice while the removal is in flight. */ private dismountedForwards; /** Emits the remembered tunnels of a dead-but-present tab, dimmed, while the reconnection window lasts. */ private pushWaitingTunnels; /** * Updates the memory of a live tab, and surfaces what a revival did *not* * bring back. * * Tabby only remounts the forwards written on the profile — a tunnel added * on the fly through the modal is silently gone after a reconnection * (verified in the installed app's `ssh.ts`: `start()` replays * `profile.options.forwardedPorts`, nothing else). Those are shown as * `lost` for a while, so the silence has a witness. */ private rememberTunnels; /** * Dismounts one duplicate forward and says so. * * Fire-and-forget on purpose: this runs inside the 2 s poll, and the poll * must not await network round trips. The WeakSet is what keeps the next * pass from dismounting (or announcing) the same object again while the * removal is still in flight. */ private dismountDuplicate; /** * The address a Local forward can be opened at, or null. * * Only Local is browsable: Remote listens on the far end of the connection, * and Dynamic is a SOCKS proxy with no page behind it. `0.0.0.0` means * "every interface", which is not an address a browser can be pointed at — * loopback is the one that reaches it. https is inferred from the usual * ports only; guessing further would produce links that fail to load. */ private static tunnelUrl; openTunnelUrl(tunnel: ActiveTunnel, event: MouseEvent): void; /** Which tunnel row has its technical details unfolded, by index — clicking a row toggles it. */ expandedTunnelIndex: number | null; toggleTunnelDetails(index: number, event: MouseEvent): void; /** Backs the chain badge in the tree — number of live forwards across every open session of this profile. */ tunnelCount(profile: PartialProfile): number; /** * Tunnels written on the profile, mounted or not. Drives a dimmed badge so * a profile carrying forwards is recognisable before it is launched — * otherwise they only ever appear once connected, which is precisely when * a surprise is least welcome. */ configuredTunnelCount(profile: PartialProfile): number; /** Backs the ↑/↓ badge in the tree — read from the precomputed map, never scanning the registry here (piège #54). */ transferActivity(profile: PartialProfile): { up: boolean; down: boolean; } | null; /** * Recomputes profileId → {up, down} from the registry's entries and the * sessionLabel → profileId map refreshActiveSessions() just rebuilt. * * Called on every `changed` event from the registry (an entry starting or * leaving `active`) and every `refreshActiveSessions()` pass (a session's * label may now resolve, or no longer). Not a getter: the row for every * profile in the tree would otherwise rescan the whole registry on every * change detection pass. */ private refreshTransferActivity; toggleActiveTunnels(): void; /** stopPropagation, not just preventDefault: the row itself is a click target now (it folds the details open), and without this the button did both. */ focusTunnelSession(tunnel: ActiveTunnel, event: MouseEvent): void; /** * The live SSH tab of a profile, if one is connected — decides which of the * two tunnel entries the profile menu offers. */ private connectedTabForProfile; /** * The plugin's own tunnel editor, and — by the user's explicit choice — * the only one. An entry handing over to Tabby's native * `showPortForwarding()` modal was built and validated alongside it, then * dropped: "je veux uniquement ma solution maison". * * What that settles, so it is not rediscovered as a bug: nothing here can * touch a session that is already running. Everything written goes to the * profile's configuration, which Tabby reads only when a session starts. * Acting on a live session would mean calling * `SSHSession.addPortForward()`, which calls `fw.startLocalListener()` and * therefore needs a genuine `ForwardedPort` — a class tabby-ssh does not * export at runtime (checked against the installed bundle, piège #13). * Rebuilding one from an existing forward's prototype would only work once * the user already had a tunnel, which is circular. * * The form itself — draft, validation, add/edit/remove — lives in * `TunnelsModalComponent` (a centred modal, like the snippets and note * editors) rather than in the anchored popup it used to be. What stays * here is what the modal cannot know on its own: whether a given forward * is one Tabby currently has mounted (`isTunnelLive`, fed by the same * poll that drives the tree's own "Tunnels actifs" section) and whether * the profile has a live session at all — both handed to the modal as * callbacks bound to `profile` below, rather than as a one-time snapshot, * so they stay accurate even if a session opens or closes while the modal * is open. */ /** Port forwarding is an SSH-profile notion — the menu entries stay hidden on local/serial/telnet profiles rather than offering a setting Tabby would ignore. */ get isSshProfileMenu(): boolean; openProfileTunnels(): Promise; toggleActiveSessions(): void; focusSession(session: ActiveSession, event?: MouseEvent): void; /** Jumps to the session *and* swaps the sidebar to its SFTP view — the panel follows the focused tab, so the order matters. */ openSessionSftp(session: ActiveSession, event: MouseEvent): void; /** * Ephemeral and never persisted — and deliberately a set of *ids* rather * than of profile objects. Every config.save() fires config.changed$ → * loadTreeItems(), which structuredClone()s the whole tree and hands back * brand-new profile objects; a set of references would therefore empty * itself silently the first time the user pinned a favorite mid-selection. */ selectedProfileIds: Set; /** Where a Shift+click extends *from*. Carries its group id because extending only ever happens within one container — see extendSelectionTo(). */ private selectionAnchor; /** * Transient confirmation shown in the selection bar *after* the selection * itself is gone. Without it the bar vanishes the instant a move lands, * which reads as "did that do anything?" — the tree has already * re-rendered elsewhere, so nothing on screen acknowledges the action. */ selectionNotice: string | null; private selectionNoticeTimer; get selectionActive(): boolean; /** * Wrapped in zone.run() because every caller reaches this *after* * `await config.save()`, and that continuation does not resume inside * Angular's zone — the field was being set correctly and the view simply * never repainted, so the confirmation was invisible from the first * version (reported in manual testing: "pas de notification"). Tabby's own * `notifications.notice()` was unaffected, which is what made the failure * look selective. * * Running inside the zone also means the setTimeout below is Zone-patched, * so its expiry triggers a change detection pass of its own and the * confirmation disappears on time without further help. */ private showSelectionNotice; isProfileSelected(profile: PartialProfile): boolean; clearSelection(): void; /** * Left-click on a profile row, with the modifier semantics of any OS file * manager: a plain click selects that row alone, Ctrl/Cmd toggles one row * in or out, Shift extends from the anchor. Double-click still launches the * profile, so selecting costs nothing. * * The tick boxes this replaced were dropped at the user's request after * manual testing — the row highlight plus the selection bar carry the state * on their own, and the row contents no longer pay a permanent indent for a * control that was only visible on hover. * * preventDefault() because the row is an `` — without it every * click pushes a history entry. */ onProfileClick(profile: PartialProfile, group: PartialProfileGroup, event: MouseEvent): void; toggleProfileSelection(profile: PartialProfile, group: PartialProfileGroup): void; /** * Extends the selection **within the anchor's own container only**. * "Everything between A and B in display order" has no honest answer * across containers here: applyFavorites() prepends a synthetic "Épinglés" * group rendering the very same profile ids a second time, a collapsed * folder renders none of its rows at all, and the filter mode replaces the * whole tree with one synthetic 'search' group. Anchored in a different * group, this degrades to a plain toggle instead of guessing. */ private extendSelectionTo; /** Whether "Déplacer la sélection ici" should show on this folder's menu — same target rule as onProfileDrop(). */ canMoveSelectionTo(group: PartialProfileGroup): boolean; /** * Moves every selected profile into `targetGroup` in one pass. * * The whole move is snapshotted *before* the first write. writeProfile() * only mutates config.store.profiles (verified in the installed bundle: it * never calls config.save()), so nothing here can fire config.changed$ and * swap this.profileGroups out from under the loop — but the order writes * below still have to be computed against the state as it was, and a single * config.save() at the very end keeps the whole batch atomic from the * config's point of view. */ moveSelectionToGroup(targetGroup: PartialProfileGroup, insertAfterProfileId?: string | null): Promise; /** * Starting a drag on a profile that is *not* part of the current selection * drops that selection, exactly as right-clicking outside it does. Without * this the user drags one row while three others stay ticked somewhere * above, with nothing on screen saying which of the two the drop will act * on — reported as confusing in manual testing. */ onProfileDragStarted(profile: PartialProfile): void; /** * Releases the tracking only — `hoveredProfileId` is deliberately kept, for * the same reason as onGroupDragEnded(): CDK emits `cdkDragEnded` *before* * `cdkDropListDropped` (piège #29), so clearing it here would wipe the value * a moment before the drop handler reads it. * * `hoveredProfileTargetGroupId` has no such reader downstream — it only * drives the nest highlight — so it is safe to clear right away. */ onProfileDragEnded(): void; /** * CDK fires this on a folder's own profile list when the dragged profile's * pointer crosses into it — not for the list the drag started in, only for * a genuine move into a different container — which is exactly "this * folder is about to receive it". `zone.run()` guards against CDK's * pointer tracking running outside NgZone (piège #41): its own * `dropped`/`started`/`ended` outputs already re-enter the zone and are * relied upon elsewhere in this file, but this pair is new here. */ onProfileListEntered(group: PartialProfileGroup): void; onProfileListExited(group: PartialProfileGroup): void; /** * True for the selected rows that are *not* the one under the cursor, while * a multi-selection drag is running: they are collapsed out of the list so * the whole batch visibly leaves together, instead of one row vanishing and * the rest sitting there as if they were staying put. * * Restoration is guaranteed by `cdkDragEnded`, which CDK emits even when a * drag is abandoned outside any drop list — so a cancelled drag puts every * row back rather than leaving them hidden. */ isHiddenWhileDragging(profile: PartialProfile): boolean; /** * The profile row physically under the pointer, measured live. * * CDK's `currentIndex` turned out not to mean what the first version * assumed — a block reordered from it landed above the targeted row every * time, whichever direction the drag came from. Rather than reverse-engineer * its convention, the drop point is resolved the same way the folder rescue * already does it: by hit-testing the rendered rows. The drag placeholder is * height: 0 here (piège #28), so the rows do not shift during a drag and * what is measured is exactly what the user is pointing at. */ private updateHoveredProfile; /** * Whether `group`'s row should carry the "drop inside" highlight right * now, as opposed to CDK's own reorder feedback. Purely a read of state * already tracked for other purposes — `hoveredGroupId`/`draggedGroupId` * drive the folder-on-folder rescue below, `hoveredProfileTargetGroupId` * tracks a profile crossing into a foreign folder — so this adds no new * hit-testing, and the class it feeds (`.sidebar-plus-nest-target`) is * background/outline only: no geometry (piège #25/#28). * * `isSelfOrDescendant` is declared further down the file; order does not * matter for class members. */ isNestTarget(group: PartialProfileGroup): boolean; get profileListIds(): string[]; onProfileDrop(event: CdkDragDrop[]>, targetGroup: PartialProfileGroup): Promise; /** * Reorders a whole multi-selection within one folder: every selected row * is pulled out and re-inserted as a single contiguous block at the drop * point. * * Placement is driven by `hoveredProfileId` — the row genuinely under the * pointer — and the block goes *below* it, which the user found the natural * reading of dropping onto a row. * * `targetIndex` (CDK's `currentIndex`) is only a fallback for when the * pointer is over no row at all, or over one of the dragged rows * themselves. Deriving the position from it was the first approach and it * was wrong in both directions: the block landed above the targeted row * whichever way the drag came from, because `currentIndex` is expressed * against the array minus the single row CDK tracked, not minus the whole * selection. */ private static moveSelectionWithinArray; /** * Sibling order is per-workspace (see configProvider.ts SidebarWorkspace) * — outside "Tous", reordering never touches the profile's own native * `weight` (which stays whatever "Tous" last set it to) and instead * writes the displayed order into the active workspace's own * `profileOrder[groupId]`. This is safe against hidden items: a * workspace's order list only needs to describe the profiles actually * visible in it, since a hidden profile is never rendered there * regardless of its recorded position. */ private persistProfileOrder; private persistProfileWeights; /** * CDK resolves a drop's target container by walking this array in order * and taking the FIRST connected list whose element contains the drop * point. Every nested `groups-` list is rendered *inside* * `#groups-root` (recursive template), so `#groups-root`'s element * DOM-contains all of them — if it came first (as it used to), it always * won, and a folder drag could never register as entering a nested * folder no matter how precisely the user targeted it (confirmed via * CDP: `containerId` was `groups-root` on every attempt, even aimed * directly at a visible child row). Deepest-nested groups must be * checked first, `groups-root` last, so an ancestor list never * shadows a descendant one it happens to contain. */ get groupListIds(): string[]; private static sortGroupIdsByDepthDesc; onGroupDrop(event: CdkDragDrop[]>, targetParentGroupId: string | null): Promise; /** * The folder the pointer was really over, when it is a legal nest target * for `draggedId` — or null, meaning the drop should stay a plain * reorder. See the "drag direction rescue" note above for why CDK cannot * be trusted to have noticed it on a downward drag. */ private rescueTargetGroupId; private reparentDraggedGroup; /** * Drops ids that match no existing group/profile from every order map — * top-level and per workspace, keys as well as values. * * Order lists accumulate dead ids on their own: re-parenting a folder * retires its id (piège #12 forces a recreate), deleting a group or * profile retires it outright, and neither path can rewrite every list * that happened to mention it. Left alone they are mostly inert, but they * make the maps unreadable when diagnosing an ordering problem, and one * of them masked a real bug once (piège #31: a list holding nothing but * dead ids still counted as "non-empty"). * * Runs on every order write rather than on load: self-repairing without * ever rewriting config.yaml behind a user who is only reading the tree. */ private pruneDeadOrderIds; /** * Drops every trace of an id that has just been deleted — the mirror image * of migrateWorkspaceGroupId(), which carries those same traces over when * an id *changes*. Same list, same consequence if one is forgotten, one * difference: a leftover here costs nothing visible, it just accumulates. * * Done at the point of deletion rather than by sweeping the config against * the live entries, which is what pruneDeadOrderIds() does for the order * maps and what this deliberately does not copy. A sweep has to answer * "does this id still exist?", and the honest answer needs the *unfiltered* * snapshot — builtin profiles and their synthetic groups included, see the * note in pruneDeadOrderIds(). Here the deleted id is known outright, so * nothing has to be inferred and a favourite can never be dropped by * mistake. * * Every workspace is visited, not only the active one: hiding and pinning * are per-workspace, and the entry the user is not looking at is precisely * the one nobody would clean up by hand. */ private forgetDeletedId; /** Sibling order is per-workspace — see persistProfileOrder() above for the same reasoning applied to groups. */ private persistGroupOrder; /** * profilesService.writeProfileGroup() only updates an existing flat * top-level config.store.groups entry — it cannot relocate a group * between parents (see roadmap piège #12: a naive parentGroupId * reassignment corrupted real user data). Instead, recreate an * equivalent group under the new parent, migrate its profiles and child * groups into it one at a time (writeProfile/newProfileGroup/ * deleteProfileGroup are all already proven safe), then delete the * now-empty original. * * Migrates from `allGroups` — a full, workspace-*unfiltered* snapshot * fetched once by the caller — rather than the dragged item's own * `.children`/`.profiles` (which come from the displayed, possibly * workspace-filtered tree). Using the filtered tree would silently * orphan anything hidden in the active workspace: its parentGroupId * would keep pointing at the original group id after that id is * deleted below. */ private reparentGroup; /** * Carries every trace of a group's id over to the one it was just given. * * Everything keyed by group id has to be listed here — a state left behind * does not fail, it silently reverts to its default the next time the tree * loads. `favoriteGroups` was the one missing: re-parenting a pinned folder * dropped its star and left a dead id nobody ever collects, since * `pruneDeadOrderIds()` only walks the order maps. */ private migrateWorkspaceGroupId; /** Renames `oldId` to `newId` both as a value wherever it appears in a sibling order list, and as the map's own parent key (siblings ordered *under* that group). */ private static renameOrderKey; /** * True if `candidateId` is `ancestorId` itself or nested somewhere under * it (used to block re-parenting a group into its own subtree). Walks * `rawGroupsSnapshot` (workspace-*unfiltered*) rather than * `profileGroups` — an ancestor chain that happens to pass through a * group hidden in the active workspace would otherwise break the walk * early and under-detect a genuine self/descendant cycle. */ private isSelfOrDescendant; onGroupContextMenu(event: MouseEvent, group: PartialProfileGroup): void; /** Right-click on empty sidebar space (not on any group/profile row) — offers root-level creation. */ onSidebarContextMenu(event: MouseEvent): void; closeContextMenu(): void; /** * Opens the given lateral submenu beside the entry that carries it — both * hover and click call this. Anchored on the CARRYING ENTRY's own rect * (not a fixed point), so clampSubmenuPosition() can later flip the panel * to the entry's left once the panel's real width is known. */ openSubmenuPanel(name: 'manage' | 'more', event: MouseEvent): void; /** Cancels a pending submenu close — the pointer made it from the carrying entry onto the panel (or onto another submenu-carrying entry) before scheduleSubmenuClose()'s delay ran out. */ cancelSubmenuClose(): void; /** Closes the open submenu after a short delay instead of immediately, so the pointer's path from the carrying entry to the panel beside it (which briefly leaves both) doesn't make it vanish underfoot. */ scheduleSubmenuClose(): void; /** The submenu dies with whatever menu carries it — called from closeContextMenu() and from every place that opens a fresh menu directly (a right-click elsewhere never goes through closeContextMenu() first). */ private resetSubmenu; onDocumentClick(event: MouseEvent): void; openRenamePrompt(): void; confirmRename(): Promise; openCreateGroupPrompt(): void; createGroup(): Promise; openCreateProfilePicker(): Promise; pickProfileTemplate(entry: { provider: ProfileProvider; template: PartialProfile; }): Promise; onProfileContextMenu(event: MouseEvent, profile: PartialProfile): void; /** * Opens the icon picker on whichever of the three targets the current * menu carries — a profile, a folder, or (from the workspace menu) a * workspace, always exactly one. Everything the picker shows and writes * — search, favorites, recents, the custom-SVG import, the per-tile * favorite toggle — now lives in `IconPickerModalComponent`, a centred * modal like the snippets and note editors, rather than in the anchored * popup it used to be. */ openIconPicker(): Promise; /** * Standard Tabby palette (TAB_COLORS, tabby-core/utils.ts) minus its own * "No color" entry — that one is offered separately below, as the * "Retirer la couleur" link mirroring clearWorkspaceIcon(), not folded * into the swatch grid. Re-labelled in French: TAB_COLORS' own `name` * fields are wrapped in `_()` for Tabby's own translation extraction and * come through unresolved ("Blue", "Green"...) since this plugin never * calls into that pipeline. */ private static readonly WORKSPACE_COLOR_NAMES; get workspaceColors(): { name: string; value: string; }[]; openWorkspaceColorPicker(): void; applyWorkspaceColor(color: string): Promise; /** Mirrors IconPickerModalComponent.clearWorkspaceIcon() — deletes the field rather than storing an empty string, so every truthiness check on it (withWorkspaceColor()'s injection guard, the tab's color dot) stays a single `if (workspace.color)`. */ clearWorkspaceColor(): Promise; /** * Opens Tabby's own profile edit modal, exactly the way `tabby-settings` * opens it for its own list and `tabby-core` for the native tree: hand it a * clone and the profile's provider, then `writeProfile()` the result. * * This used to drive tabby-settings' DOM instead — open Settings > * Profiles, click every collapsed group open, find the row whose `span` * carries the profile name, click it, then poll for `.modal-content` to * learn when to close the tab again. That was the single most fragile * point of the plugin, and it was never necessary: the comment justifying * it claimed no public API existed, while `pickProfileTemplate()` opened * the very same modal a few hundred lines above. `EditProfileModalComponent` * is `@hidden`, which proves nothing about what is exported at runtime * (piège #13), and `src/tabby-settings-augment.d.ts` already declared it. * * That left two callers naming the modal's inputs by hand, which is what * `openProfileModal()` (`src/profileModal.ts`) now holds — one place to * assign them, one place to check they still exist. * * Opening a Settings tab was the *vehicle*, never the intent. It survives * only as the fallback below, for a profile whose provider cannot be * resolved — where Tabby itself throws. */ editProfile(profile?: PartialProfile): Promise; /** * "Dupliquer" — a copy of the profile, in the same folder, named * " - Copie". * * `structuredClone()` first, always: `newProfile()` pushes the very object * it is handed into `config.store.profiles`, so a shallow copy would leave * both entries sharing one `options` object — editing either would silently * edit the other. `contextMenuProfile` happens to be detached already * (`readProfileGroups()` clones the whole tree), and relying on that is * exactly the kind of assumption piège #12 is made of. * * `isBuiltin`/`isTemplate` are dropped like `pickProfileTemplate()` does: a * copy of a provider-contributed profile belongs to the user, and keeping * the flag would file it back under the synthetic "built-in" group, which * is not editable. Their `group` is a provider-declared *name*, not a real * group id, so it goes too — the copy lands in "Ungrouped", where the user * can pick it up. * * `weight` is kept on purpose: same rank as the original, and `sort()` * being stable, the copy lands right underneath it rather than at the end * of the folder. `id` is left alone — `newProfile()` overwrites it with a * fresh uuid derived from the new name. */ duplicateProfile(profile: PartialProfile): Promise; /** * "X" → "X - Copie", then "X - Copie 2", "X - Copie 3"… against the names * already in the same folder. * * The bare suffix is what the roadmap asked for, and it is enough right up * until the gesture is repeated: duplicating the same profile twice would * otherwise put two identically named rows in the tree, with nothing on * screen telling them apart. * * Duplicating a *copy* is a different case and keeps stacking — * "X - Copie - Copie" — because that name is genuinely free. Worth knowing * before reading it as a bug: it looked like one during the test of * 2026-08-03, until the gesture was retraced. * * Read off `rawGroupsSnapshot` rather than `config.store.profiles`: the * latter does not hold provider-contributed profiles, and this only has to * be right about what the folder *displays* (piège #74). */ private copyNameFor; /** * The memo of a profile or folder, empty when there is none. * * Not inherited, unlike the snippet maps that sit beside it in the config: * a folder's note repeated on each of its profiles would be noise, so a * folder's note belongs to the folder and shows on its own row. */ noteFor(id: string | undefined): string; /** * Whether a badge is due on this row. * * Gated on the block rather than on the text alone: switching notes off has * to take the badges with it, and the note itself survives untouched for * when it comes back on. */ hasNote(id: string | undefined): boolean; openNoteModal(): Promise; /** Puts the same note on a second id, leaving the first alone — used when a profile is duplicated. */ private copyNote; /** Carries a note over when a folder is given a new id, or drops it when an entry is deleted. */ private moveNote; /** * "Copier la structure (JSON)" and "Copier sans les identifiants". * * Two entries rather than one setting: the level belongs to the gesture, * not to a preference — the same folder goes to one's other machine whole * and to a colleague stripped, and there is no reason to walk to the * settings between the two. * * Reads `rawGroupsSnapshot`, the workspace-*unfiltered* tree, for the same * reason `reparentGroup()` does: a folder half-hidden in the active * workspace would otherwise export as half a folder, silently. */ copyGroupStructure(level: PurgeLevel): Promise; /** * "Coller le groupe", from the right-click on empty sidebar space. * * At the root only, which is where the roadmap put it. Pasting *into* a * folder is the same code with a different parent, but the folder menu * already carries eleven entries and nobody has asked. * * Nothing here trusts the payload: `parsePayload()` re-runs the purge on * the way in, because the JSON is text off the clipboard — hand-editable, * possibly written elsewhere — and its own account of what it had removed * is worth nothing. */ pasteGroup(): Promise; /** * Writes one shared folder and everything under it into the config. * * `target` is set only when merging: the folder already exists, so nothing * is created for this level and the contents land inside it. A sub-folder * colliding *inside* a merge is not asked about again — it is created * alongside, which the modal says out loud. * * `newProfileGroup({ genId: true })` mints the id and writes it back onto * the object handed over, exactly as `reparentGroup()` relies on. */ private applySharedGroup; /** * "Prod" → "Prod - Copie", then "Prod - Copie 2", among the root folders. * * Same shape as `copyNameFor()` for profiles, deliberately: two gestures * that produce a near-duplicate should name it the same way, and the user * has already read that suffix once. */ private rootGroupCopyName; /** How long a snippet waits for a session it asked to be launched, and how often it looks. */ private static readonly SNIPPET_LAUNCH_POLL_MS; private static readonly SNIPPET_LAUNCH_ATTEMPTS; /** * Opens the snippets modal on the right-clicked profile or folder. * * Everything it shows and writes lives in `SidebarPlusSnippetsService`. * What stays here is what needs the sidebar's own reach: running a snippet * into a session, and opening the settings tab. */ openSnippetsModal(): Promise; /** * Opens one of the footer's outbound links in the user's browser. * * `platform.openExternal()`, never a plain `href`: an anchor left to * navigate would replace the whole Electron window with the target page, * taking every open session down with it. Same call the tunnel rows make. */ openExternalLink(url: string, event: MouseEvent): void; /** * Opens the plugin's settings tab, resolving whichever tab id currently * hosts it — 'better-tabby' when the "Better *" family is unified, * 'better-sidebar' when this plugin is alone. Never hardcode the id (see * `electBetterPanelHost()`); callers that want a specific section set * `SidebarPlusSettingsTabComponent.requestedSection` first. */ openPluginSettings(): void; /** * Writes a snippet into the profile's session. * * Both behaviours are resolved from the chain, so the answer may come from * the profile, from its folder, or from one further up. */ private runSnippet; /** * Any terminal tab currently backed by this profile — SSH or not. * * Deliberately wider than `connectedTabForProfile()`, which answers the * same question for tunnels and has to stay SSH-only. What a snippet needs * is something that can be typed into, i.e. a `BaseTerminalTabComponent`, * so a local profile gets snippets too. * * `instanceof` is safe on this class where it is not on `SSHTabComponent`: * Tabby's plugin loader caches `tabby-terminal`, so only one copy of it is * ever loaded (piège #34 is about `tabby-ssh`, which it does not cache). * `hotkeys.ts` already leans on the same test. */ private terminalTabForProfile; /** * Polls for the session a launch is bringing up. * * There is no "the remote shell is ready" signal to await: `launchProfile()` * resolves once the tab exists, and the transport comes up some time later. * So the tab is polled for, and what the snippet then hits is whatever the * prompt is at that moment — on a server with a long MOTD the text can land * mid-banner. Survivable because the default is to *type* the command * rather than run it. The optional wait is for the servers where it is not * enough. */ private waitForTerminalTab; /** Settings > Profiles. Only reached when a profile has no resolvable provider. */ private openProfilesSettingsTab; /** Reuses a Settings tab the user already had open rather than stacking a second one. */ private openSettingsTab; /** * Refuses to delete a folder that still holds anything — counted on the * *unfiltered* snapshot, not on the node as displayed. * * The displayed node has already been through the active workspace's * hide lists, so a folder whose whole content is hidden there looked empty * and sailed past this guard. `deleteProfileGroup()` then removed the group * and cleared `group` on every profile that lived in it: the hidden * profiles resurfaced in "Ungrouped", visible everywhere, and hidden * subfolders were orphaned up to the root. `rawGroupsSnapshot` is the same * source `isSelfOrDescendant()` and `rescueTargetGroupId()` already consult * for exactly this kind of check. */ deleteGroup(group: PartialProfileGroup): Promise; confirmDeleteProfile(): void; deleteProfile(profile: PartialProfile): Promise; } export {};