import './sftpBrowser.component.scss'; import { AfterViewChecked, ElementRef, NgZone, OnDestroy } from '@angular/core'; import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; import { ConfigService, LocaleService, NotificationsService, PlatformService } from 'tabby-core'; import { SFTPContextMenuItemProvider, SFTPFile, SFTPPanelComponent } from 'tabby-ssh'; import { SidebarPlusEditorService } from '../editorLauncher.service'; import { SidebarPlusDragOutServer } from '../dragOutServer.service'; import { SidebarPlusNoticesService } from '../notices.service'; import { SidebarPlusI18nService } from '../i18n'; import { SidebarPlusTempFilesService } from '../tempFiles.service'; import { SidebarPlusTransfersService } from '../transfersRegistry.service'; /** * One rendered row, computed once instead of on every change detection pass. * * The template used to call a method for the icon, the tooltip and each cell. * Angular re-runs those on *every* cycle, and Tabby runs a great many of them — * a live terminal alone is enough. On a directory with a few thousand entries * that meant thousands of date and size formatting calls per cycle, which is * what made a full `/tmp` unusable rather than merely slow. */ export interface SftpRow { item: SFTPFile; icon: string; tooltip: string; hidden: boolean; /** Formatted values, positionally aligned with `visibleColumns`. */ cells: string[]; } /** An optional column of the file list. The name column is not one of these — it is always shown. */ export interface SftpColumn { id: string; /** Header caption. Kept short: the whole list lives in a ~300px sidebar. */ label: string; /** Full wording for the column chooser, where there is room for it. */ description: string; /** A fixed grid track — fixed is the point, it is what keeps the columns aligned at any width. */ width: string; /** * Right-aligned. Deliberately only the size: `4.2 MB` / `856 B` / `1.1 GB` * left-aligned in a 60px track makes magnitudes unscannable, which is the * one thing a size column is for. SFTP+ left-aligns everything; this is * the single place this panel departs from it. */ numeric?: boolean; } /** * Tabby's own SFTP panel with this plugin's presentation. * * Subclassing rather than reimplementing: every behaviour worth having — * navigation, open, upload/download, the context menu, the filter, transfer * plumbing — is inherited untouched from `SFTPPanelComponent`, and only the * template is ours. What forced the split is that the native template renders * the date through the `tabbyDate` pipe, the permissions through * `getModeString()`, and carries no `title` anywhere: all of that is template * content, unreachable from a stylesheet, and patching the DOM instead would * just be overwritten on the next change detection pass. * * The native row also puts an `*ngIf` on its size cell, so directories have * one fewer child than files and the columns cannot line up. Ours is a CSS * grid with fixed column tracks, so a directory simply leaves its size track * empty and everything stays aligned at any width. */ export declare class SidebarPlusSftpBrowserComponent extends SFTPPanelComponent implements OnDestroy, AfterViewChecked { private config; private locale; private notify; private ngbModalService; private elementRef; private zone; private editors; private notices; private i18n; private dragServer; private registry; /** * Everything `SFTPFile` can actually answer — it carries only name, * fullPath, isDirectory, isSymlink, mode, size and modified, so there is * no owner/group column to offer however much an SFTP client usually has * one. * * Translated through explicit calls to `i18n.t()`, each with its own * string written out in full, rather than the template's `| translate` * pipe: this list is consumed as a bound property * (`*ngFor='let column of availableColumns'`), never a literal, and the * i18n lint script's extraction is lexical — it only recognises a * literal immediately piped to `translate` in a `.pug` file or passed * directly to `i18n.t()` in a `.ts` file (piping `column.label` came * back CLÉ MORTE while building this lot). A getter rather than a field * computed once keeps a live language change picked up on the next read * — this is six entries, read only while the rarely-open display menu is * on screen, nothing like the row list `visibleColumns`/`rows` below * guard against. */ get availableColumns(): SftpColumn[]; /** * The display toggles that sit under the column list in the header menu, * modelled on SFTP+'s. Each is a `sidebarPlus` key, declared in the * ConfigProvider defaults (piège #16). Same translation reasoning as * `availableColumns` above. */ get displayToggles(): { key: string; label: string; }[]; /** * Full paths of every selected entry — files and folders mixed freely. * `Set` rather than an array: `isSelected()` runs for every row on every * change-detection pass (see the doc comment above `rows`), and a `Set` * lookup is the O(1) that requires. */ private selection; /** * The last entry clicked *without* Shift — a plain click or a Ctrl/Cmd * click, both of which start a fresh anchor by the usual desktop * convention. A Shift+click ranges from here to the clicked row, in * **displayed** order, and never moves the anchor itself: a second * Shift+click extends or shrinks the same range instead of restarting it * from wherever the previous one landed. */ private selectionAnchor; private editor; private dragOut; constructor(config: ConfigService, locale: LocaleService, notify: NotificationsService, ngbModalService: NgbModal, elementRef: ElementRef, zone: NgZone, editors: SidebarPlusEditorService, platform: PlatformService, temp: SidebarPlusTempFilesService, notices: SidebarPlusNoticesService, i18n: SidebarPlusI18nService, dragServer: SidebarPlusDragOutServer, registry: SidebarPlusTransfersService, contextMenuProviders: SFTPContextMenuItemProvider[]); /** The transfers helper shared with the editor and the drag-out — kept for routes started here. */ private readonly fileTransfers; /** The constructor's `platform` goes to `super` — kept under our own name for the routes below. */ private readonly platformSvc; private _sessionLabel; /** * Display name of the SSH tab this panel serves, set by the host panel at * creation. Forwarded to the transfers helper, which stamps it on every * registry line this panel starts — and passed along with each drag-out * offer, whose delivery happens long after this component may be gone. */ set sessionLabel(value: string | null); get sessionLabel(): string | null; /** * True when the SFTP channel could not be opened at all. * * Read by the host panel, which has no other way of telling a browser that * is merely empty from one that never got a channel. */ sftpUnavailable: boolean; /** What the server or the transport actually said — shown rather than guessed at. */ sftpUnavailableReason: string; /** * `super.ngOnInit()` calls `session.openSFTP()` **outside** its own * try/catch — only the `navigate()` that follows is guarded. So a server * without an SFTP subsystem, or a transport that died between the tab * opening and the panel being built, rejects here and takes the whole * `ngOnInit` down: unhandled rejection, `this.sftp` left undefined, and a * panel that shows nothing and says nothing. Catching it is what turns a * silent dead view into a state the panel above can act on. */ ngOnInit(): Promise; ngOnDestroy(): void; /** * Serves the file ourselves and lets Chromium write it, instead of the * inherited `platform.startDownload()` path. * * The inherited one calls `this.sftp.download(...)` **without awaiting * it** (`sftpPanel.component.ts:252` of the installed app), so a transport * that dies mid-transfer rejects a promise nobody holds: the failure never * reaches us, and all the panel sees is the `transfer.cancel()` tabby-ssh * makes on its way out — a line reading "annulé" for something the user * never cancelled. Going through our own HTTP offer puts the rejection back * in a catch we own, which is what tells a dead transfer apart from a * cancelled one. * * **Overriding `download()` and not `downloadItem()` is the point.** This * used to hook the latter, which left the whole thing resting on an * unwritten assumption: that the context menu entry calls * `panel.downloadItem(item)` (it does, in `sftpContextMenu.ts`). If Tabby * ever pointed that entry at `download()` instead, the override would have * stopped being reached — no error, no sign, just the "annulé" label * quietly back. `download()` is where both routes end up, `open()` * included, so which one the host picks stops mattering. That is one * unverifiable assumption removed rather than watched. * * Chromium does the writing, so what is given up is the native transfers * menu's progress bar — a trade the user made knowingly on 2026-08-02, this * plugin hiding that menu by default and its own panel saying more * (percentage, speed, ETA). * * Directories and symlinks stay on the inherited path, as before: a * `DownloadURL` offer serves exactly one file, and a link is only ever * resolved through the `stat()` piège #50 warns against — its `size` is * sound but its mode is not, and there is nothing here worth that risk. * `downloadFolder()` never comes through here at all. */ download(itemPath: string, mode: number, size: number): Promise; /** * Sends « Download directory » down this plugin's own route instead of the * inherited one. * * The inherited route *does* await its transfers, but converts any failure * into `transfer.cancel()` on its single aggregated line — so a transport * that dies mid-download reads « annulé » for something the user never * cancelled, exactly the confusion `download()` above was moved off of. * Going through `SftpTransfers.download()` gives every file its own line, * a real `failed` state when one dies, and the arrival check — the folder * route is the one place where the destination is a path we chose, so * « le fichier est-il bien arrivé ? » is actually answerable here. * * Same shape as the marker delivery in `SidebarPlusDragOutServer.deliver()`: * pick a folder, avoid collisions the Explorer way, then walk and fetch * four files at a time. The directory dialog is ours to raise since the * inherited `startDownloadDirectory()` is never called — `pickDirectory()` * answers null on cancel, which ends the gesture without a word, like the * native route. */ downloadFolder(folder: SFTPFile): Promise; /** * Whether something now decides where downloads land. * * The second thing our route rests on: Tabby installs **no** * `will-download` handler, which is what leaves Electron free to raise its * own "Save as" dialog — the same prompt the inherited path used to raise. * A handler appearing there would silently change what the user gets, and * could cancel the download outright, which would be a click that does * nothing at all. * * Rather than document that as a thing to re-check after a Tabby update, * it is asked. `listenerCount` is an EventEmitter method, i.e. actually * contractual, unlike everything else in this file's neighbourhood. * Measured at 0 on the installed app. When it is not, the inherited route * is the honest answer: `platform.startDownload()` is what Tabby would * expect to be handling. */ private downloadsAreIntercepted; private autoRefreshTimer; /** * Reloads the listing on a timer, because nothing else does. * * SFTP has no change notification: a file created or removed from a shell * simply does not appear until the directory is read again. Off by default * (0 seconds) — a poll on a large directory is a full `readdir` each time, * and the user is the one who knows whether that trade is worth it here. * * Skipped, never queued, while anything is in the middle of something: a * menu open, a path being typed, a delete in flight. Refreshing under an * open menu would rebuild the rows beneath it, and the selection with them. */ private startAutoRefresh; private stopAutoRefresh; private autoRefresh; /** * Rereads the current directory and merges the result in. * * Deliberately *not* `navigate()`: that one clears the list before * refilling it, so every row is destroyed and rebuilt — the visible jump, * and a full re-render of a directory that usually has not changed at all. */ private refreshListing; /** * Merges a fresh listing into the current one, touching only what differs. * * Nothing changed ⇒ nothing is reassigned, so `filteredFileList` keeps its * identity, the sorted list and the row cache stay valid, and the refresh * costs exactly one `readdir` and no rendering whatsoever. When something * did change, unchanged entries keep their *existing object*, which is what * lets `trackBy` hold on to the rows already on screen — the list updates * in place instead of blinking, and the scroll position survives. */ private applyListing; private sameEntry; /** Create actions, on a right-click over the empty area of the listing. */ backgroundMenuOpen: boolean; /** Columns and display toggles. Opened by right-clicking the header — where SFTP+ puts it, and where one looks for column settings — or from the background menu. */ displayMenuOpen: boolean; backgroundMenuX: number; backgroundMenuY: number; /** Set when either menu opens, consumed once in ngAfterViewChecked — a menu has no measurable size until Angular has rendered it (piège #30). */ private backgroundMenuDirty; /** * Right-click anywhere in the listing that is not an entry. * * Entry rows carry their own `(contextmenu)` and this handler sits on * their container, so without the guard below a right-click on a file * would open both menus. The header is deliberately *not* excluded — it is * neither a file nor a folder, and reaching the display settings by * right-clicking the column titles is where one would look first. */ onBackgroundContextMenu(event: MouseEvent): void; /** * Closes the menu on any click outside it. * * The `closest()` test is not belt-and-braces: a `document:click` * HostListener fires even when a descendant called stopPropagation() on * the event, so checking the target explicitly is the only thing that * keeps clicking a menu item from dismissing the menu first (piège #15). */ onDocumentClick(event: MouseEvent): void; ngAfterViewChecked(): void; createDirectoryFromMenu(): void; /** Swaps one menu for the other in place, so the display settings appear where the cursor already is. */ openDisplayMenu(): void; /** * Creates an empty remote file. * * `SFTPSession` offers no "create file", so this goes through `upload()` * with a zero-byte transfer — see EmptyFileUpload. Mode 0o644, the usual * default for a new file; the context menu on the entry can change it * afterwards. */ createFileFromMenu(): Promise; /** * Row click. Plain click replaces the whole selection with this entry; * Ctrl/Cmd+click toggles it in/out without touching the rest; Shift+click * ranges from the anchor to here, in the order the list is rendered in * (`displayedFiles`), replacing whatever was selected before. `event` is * optional so every non-interactive caller — `openEntry()` navigating * into a directory, for instance — keeps making a plain single selection * without having to fake one. */ select(item: SFTPFile, event?: MouseEvent): void; /** The Shift+click range: replaces the selection with the anchor→target span, in display order. */ private selectRange; isSelected(item: SFTPFile): boolean; /** Every selected entry still present in the current listing, in display order. Feeds bulk delete/move and the `Suppr` shortcut. */ private selectedItems; /** Empties the selection and forgets the Shift+click anchor — background click, navigating away, or anything else where what was selected stops meaning anything. */ private clearSelection; /** * Overridden purely to clear the selection when the directory actually * changes. `goUp()`, the breadcrumb links and every other navigation * `SFTPPanelComponent` offers all funnel through this one inherited * method, so overriding it is the single point that catches all of them * at once rather than threading a clear through each call site. A * same-path call — what the "Actualiser" button sends — is a refresh, not * a move to somewhere else, and leaves the selection alone. * * The rendered chunk resets unconditionally though, same-path call * included: `navigate()` clears `fileList` before rereading it, which is * what already sends the panel's scroll back to the top today (the * `.sftp-grid` disappears behind "Chargement…" and comes back empty) — * resetting `renderCount` here just keeps the two in step rather than * leaving the previous chunk size to briefly outlive a listing it no * longer describes. */ navigate(newPath: string, fallbackOnError?: boolean): Promise; /** * Overridden purely to reset the rendered chunk: the search bar can turn * a five-thousand-entry directory into fifty matches, and what was on * screen before has nothing to do with what belongs on screen now. */ onFilterChange(): void; /** Same reasoning as `onFilterChange()` above, for the reverse direction. */ clearFilter(): void; /** * Double-click. Directories navigate, as the inherited `open()` does; files * take the edit round-trip instead of `open()`'s save-file dialog. * * Always `editor` mode: a double-click must never reach the OS association, * which would run an executable rather than open it. `openWith` exists, but * only behind the explicit context-menu entry. */ openItem(item: SFTPFile): Promise; /** * `isDirectory` is not enough on its own. * * `SFTPSession` fills it from `metadata.type`, a field the SFTP v3 wire * format does not carry — servers answer `SSH_FXP_STAT` with a permissions * word and nothing else, so a `stat()` on a symlink's target can come back * with `isDirectory === false` for a directory. The mode does carry the * type (Tabby's own `getModeString()` reads it the same way), so the two * are checked together. Without this, a symlink to a directory fell through * to the download path and the server answered `Failure` — "Impossible de * télécharger ", found in testing on 2026-07-30. */ private static readonly S_IFMT; private static readonly S_IFDIR; private isDirectoryEntry; /** A symlink chain longer than this is a loop as far as we are concerned. */ /** * Follows a symlink to the entry it really designates — the shared * resolver in remoteEntry.ts, kept behind a method so the many call sites * of this file did not all have to change when it moved there. */ private resolveSymlink; /** * Opens an entry: directories navigate, files take the edit round-trip. * * A symlink is resolved to its target **and the target is what gets * edited** — path included. Keeping the link's own path here is what used * to make three things go wrong at once, none of them visible at the time: * the mode read for the download came from `stat()` and was 0, so the local * copy was created read-only and the editor could not save; the mode * restored after an upload was the *link's* (`0o120777`), so the file came * back world-writable; and the freshness check compared the link's size and * date, which never move when the target changes, so conflict detection was * dead. Writing to the resolved path settles all three, and has a fourth * effect worth stating: `SFTPSession.upload()` unlinks its target before * renaming over it, so editing "through" the link used to replace it with a * regular file. It no longer touches the link at all. */ private openEntry; /** * Past either of these, a directory is confirmed before being downloaded. * Low on purpose: the cost being guarded against is not disk space but a * gesture that appears to do nothing for minutes, with no way to cancel it. */ private static readonly DRAG_OUT_MAX_FILES; private static readonly DRAG_OUT_MAX_BYTES; isDragPreparing(item: SFTPFile): boolean; /** * Native drag towards the OS. * * `preventDefault()` in every path: the HTML drag is never the one that * runs. Either `startDrag()` takes over immediately — the entry is already * downloaded — or this gesture only prepares the copy, and the next drag is * the one that carries it out. Letting the HTML drag proceed would drag the * row's text into whatever accepts it. */ /** * True while the mouse button that started a drag gesture is still down. * * `onDragStart` calls `preventDefault()`, so there is no HTML drag and * therefore no `dragend` to listen for — the button state is the only * remaining evidence that the user is still holding the gesture. */ private gestureHeld; /** Whether a directory may leave for the OS at all. Off by default; files are always draggable. */ private get canDragOutFolders(); onDragStart(item: SFTPFile, event: DragEvent): void; /** * The copy-to-OS route: hand over the local copy if there is one, otherwise * take it and say so. */ private copyToOS; private prepareDrag; /** * Full path of the directory whose *next* drag goes to the OS. * * One gesture cannot serve both purposes. Moving a row needs the HTML drag * to run; copying a directory out needs `preventDefault()` and Electron's * `startDrag()`, which only accepts a path that already exists on disk — so * the two are mutually exclusive, and a file only escapes the choice because * a `DownloadURL` rides along with the HTML drag. * * The gesture is therefore split in two, which is what the copy already did * anyway ("prepare, then drag again"): dragging a directory out of the * window without dropping it states the intent and starts the download, and * the gesture after that is the one that carries it away. Dragging it inside * the panel moves it, always. */ private dragOutIntent; /** True once, for the directory that asked. Cleared as it is served, so the gesture after is a move again. */ private claimDragOutIntent; /** * Past this long without a `dragover` anywhere in the window, the pointer is * taken to be outside it. * * `dragover` fires at mouse-move rate while the cursor is over the document, * and stops the moment it leaves — so the age of the last one is what tells * "dropped on the desktop" from "let go inside Tabby". Preferred over * counting `dragenter`/`dragleave` pairs, which Chromium already reports * unreliably here (see `onDragLeave`). */ private static readonly WINDOW_EXIT_MS; private windowExitCandidate; private lastPointerInWindow; private windowExitWatcher; private watchForWindowExit; private stopWatchingWindowExit; /** * Whether the gesture that just ended was aimed out of the window. * * Both halves matter: a drop the panel accepted ends with its own * `dropEffect`, and a gesture let go anywhere inside Tabby has a fresh * `dragover` behind it. */ private wasAimedOutOfWindow; /** * Asks before pulling a large directory down, and only then. * * The count itself stops at the thresholds (see `weigh()`), so a small * directory costs one quick walk and no question at all. */ private confirmHeavyDirectory; /** * The panel's one yes/no question, in HTML rather than a native dialog * (piège #42). Handed to the remote editor as a callback so that it can ask * about a conflict without knowing anything about modals. * * Always defaults to Cancel: every caller asks before something * irreversible — overwriting a remote file, discarding local edits, pulling * a large tree — and a reflex `Entrée` must never be the destructive answer. */ private ask; /** * Where a drop would land, while one is being dragged over the list. Null * when nothing is. The row bearing that path is the one highlighted; when * it is the current directory, the body is outlined instead. */ dropTargetPath: string | null; /** Set when the `..` row is the target — the one destination that is not a row of the listing. */ dropOnUpRow: boolean; isDropTarget(item: SFTPFile): boolean; get dropInCurrentDirectory(): boolean; /** * Whether this drag is files coming in from the OS. * * A drag *out* of this panel announces itself as `DownloadURL`, and * Chromium reports it over the source window too — without this it would be * read as an incoming drop and the file uploaded back on top of itself. */ private carriesFiles; /** * Whether this drag is a row of this very panel, on its way to another * directory. * * Asked *before* `carriesFiles()` by both handlers below: a file dragged * from here announces `DownloadURL` — and therefore `Files` — as well, so * the two questions overlap and only the order settles which one answers. */ private carriesInternalPath; /** * Where a drop over this element would land. * * Deliberately synchronous — it runs on every `dragover`, which fires * continuously while the cursor moves. A symlink is therefore answered as * itself and only resolved at the drop: telling a link to a directory from * a link to a file costs a `readlink` round trip, which has no place here. * * Anything that is not a directory row — a file, the header, the empty area * below the list — answers null, meaning the current directory. That is the * guard the roadmap asked for: a drop can never land somewhere the user did * not aim at, only in the directory already on screen. */ private aimFrom; onDragOver(event: DragEvent): void; /** * Long enough to outlive a spurious `dragleave`, short enough not to leave a * highlight behind. `dragover` fires at mouse-move rate while the pointer is * inside, so any delay above a frame or two bridges the gap. */ private static readonly DROP_CLEAR_MS; private dropClearTimer; /** * `dragleave` fires on every crossing between children, so the only useful * question is whether the cursor left the panel altogether. * * The answer is not always reliable: Chromium reports a null * `relatedTarget` on some crossings, which reads as "left the panel" for a * pointer that never went anywhere. Hence the delay — a `dragover` still to * come cancels it, and the highlight stops flickering under a moving cursor. */ onDragLeave(event: DragEvent): void; private clearDropTarget; onDrop(event: DragEvent): void; /** * Takes hold of what was dropped, synchronously. * * `dataTransfer.items` is only readable while the event is being * dispatched — `webkitGetAsEntry()` answers null once the handler has * returned — so the entries have to be claimed here, before the first * await. The entries themselves stay valid afterwards; the item list does * not. */ private claimEntries; private receiveDrop; /** * Turns the aimed row into the directory the files are written under. * * The symlink case is the one that needed a decision: the row was * highlighted on `isSymlink` alone, so the link still has to be followed * here. When it does lead to a directory, the destination is the **link's** * own path rather than the target's — the server resolves it, and it is the * location the user aimed at. `openEntry()` navigates the same way. */ private resolveDestination; /** Flattens what was dropped into what has to be created and what has to be sent. */ private planFromEntries; /** * Which of the planned files already exist on the server. * * `SFTPSession.upload()` writes to `.tabby-upload` and renames it * over the target, so an existing file is replaced without a word. That was * tolerable while a drop could only land in the directory on screen; now * that any row can be aimed at, a slip of the cursor is enough. * * One listing per destination directory rather than one lookup per file: * `readRemoteEntry()` reads the parent listing anyway, so asking it file by * file would re-read the same directory N times. A directory that does not * exist yet simply answers nothing, which is the right answer. And never a * `stat()`, which follows links and reports a dangling one as free (piège * #50). */ private findCollisions; /** Names enumerated up to this many; past it, a count. A modal listing forty files says nothing. */ private static readonly COLLISIONS_NAMED; private confirmOverwrite; /** * Runs a plan through to the end and answers with the names that failed. * * Each transfer is built and registered when its turn comes rather than up * front. The panel's elapsed time counts from the moment a line is * registered, so queueing fifty of them at once would show forty-nine * clocks running against 0 % — forty-nine transfers that read as stalled * when they have simply not started. The uploads are sequential: one SFTP * channel carries them all. */ private runPlan; private reportDrop; /** * The rows a drag started from, for as long as the gesture lasts — the * whole selection when the grabbed row was part of one with more than one * entry, just that row otherwise (see `onDragStart()`). * * Kept here rather than read from the `DataTransfer` because `getData()` is * write-only until the drop: during `dragover` the engine exposes the list * of types and nothing else, and the target highlight has to be decided on * every one of those events. Empty while nothing is being dragged, and * empty as well for the gestures that call `preventDefault()` — those have * no HTML drag, hence no move to offer and no `dragend` to clean up after. * * A panel can only ever see its own: `detachPanel()` takes the whole root * node out of the document, so only the focused tab's browser is there to * receive a drop. */ private internalDragSources; /** Dims every row being moved, so the gesture reads as coming from somewhere. */ isDragSource(item: SFTPFile): boolean; /** * Ends the gesture, dropped or not. * * `dragend` fires on the source element whatever the outcome, including a * drop outside the window and an `Échap` — which is what makes it the one * reliable place to forget the source, and the one place from which a * directory dragged towards the OS can be recognised at all. */ onDragEnd(event?: DragEvent): void; /** * A directory was dragged out of the window and let go there. * * Nothing can have reached the desktop — the HTML drag carried a type the * OS ignores — so this is where the copy starts, and the *next* drag on the * same row is the one that hands it over. That two-step shape is not new: * it is what a directory drag-out has always done, `startDrag()` needing a * file that already exists on disk. */ private askedToLeave; /** * Whether the row being dragged could land on what is currently aimed at. * * Synchronous, like `aimFrom()` and for the same reason: it runs on every * `dragover`. So a symlink is judged on its own path — a link leading back * inside the dragged directory is only caught at the drop, by the same test * run against the resolved destination. * * Answering false is what leaves `preventDefault()` uncalled, so the drop * cannot happen at all: an impossible move is refused by the cursor rather * than by an error message after the fact. */ private canMoveTo; /** * Whether a path is the other one or lives underneath it. * * The separator matters: without it `/tmp/ab` would read as being inside * `/tmp/a`. Same guard as the profile tree's own `isSelfOrDescendant()`, * against a move that would make a directory its own ancestor — the server * usually refuses it, but not always, and a `rename` that succeeds there * detaches the whole subtree. */ private isSelfOrDescendant; /** * Moves every dragged entry into the aimed directory. * * `rename()` on a server-side move costs nothing and transfers nothing — * within one filesystem it is a directory entry being rewritten. Across two * mount points the server refuses outright, which is reported as it comes * rather than guessed at beforehand: SFTP has no way to ask where a path is * mounted. * * The announced payload is checked against the remembered rows rather than * used on its own: a mismatch means the drag did not come from this * panel's own state, and the safe answer to a move whose source is * uncertain is not to make it. Compared as sets, not as an ordered pair — * `JSON.stringify()` on the sending side and the engine's own string * transport give no guarantee the order survives, and membership is all * this check is for. */ private receiveMove; /** * Set for the duration of confirm-then-delete, from either trigger below. * Without it, holding Delete re-fires `onDocumentKeydown` at keyboard * repeat rate while the confirm modal is up (focus lands on one of its * *buttons*, not an input, so the input/textarea check doesn't help), and * a second click on the context menu entry before the first delete * finishes would race two deletions of the same path. */ private deleteInFlight; /** * Overridden rather than left inherited: the native `showContextMenu()` * (`SFTPPanelComponent`/`CommonSFTPContextMenu`) confirms its "Delete" * entry with `platform.showMessageBox()` — a native OS dialog, exactly * what this plugin's confirmations are meant never to be (piège #42). * `CommonSFTPContextMenu` is the only built-in provider, and always * pushes its "Delete" entry last (verified on the installed source), so * it is dropped by position unconditionally — the label is only checked * to `console.warn` if a future Tabby version ever reorders it, rather * than gating the removal on a translation string this plugin doesn't * control (the installed app has no French catalog entry for it — it * would render in English regardless of this plugin's own locale). * * Every entry pushed below — "Ouvrir avec...", "Renommer...", "Supprimer" * — acts on `item`, the row that was right-clicked, never on the * selection as a whole; same for the double-click handler above. Only the * bulk delete entry appended last is selection-aware, and only appears * when `item` itself is part of one. */ showContextMenu(item: SFTPFile, event: MouseEvent): Promise; /** * Clicking the empty area below the rows clears the selection. * * The listing has no separate background element — the empty space is the * body's own padding — so this fires for row clicks as well and has to * ignore them. `closest('.sftp-row')` rather than a target equality test: * a click lands on the icon or the label inside a row, never on the row * element itself. */ clearSelectionFromBackground(event: MouseEvent): void; /** * Renames a remote entry, extension included. * * The field is prefilled with the **whole** name rather than the stem: the * user asked to be able to change the extension, so it has to be there to * be edited. Nothing warns about changing it either — on a remote server * that is a deliberate act, not a slip. * * Only a rename, never a move: a `/` is refused rather than quietly * relocating the entry somewhere else. `rename()` would accept a path * happily, which is exactly why it is worth blocking here. */ private renameEntry; /** `Suppr` on the selection — a single entry takes the same confirm-then-delete path as the context menu's "Supprimer", several take the bulk one further down. */ onDocumentKeydown(event: KeyboardEvent): void; confirmAndDelete(item: SFTPFile): Promise; private runDeleteConfirmation; /** * `Suppr` / context menu "Supprimer la sélection (N)" when more than one * entry is selected. One confirmation for the whole batch — a count and * an irreversibility reminder, since it now stands in for every item — * then the same `deleteRecursive()` as the single-entry path above, * sequentially rather than in parallel: a directory recursion already * issues many requests of its own, and racing several of those against * the same SFTP session is not a trade worth making for a background * operation. Same `deleteInFlight` guard as `confirmAndDelete()`, held for * the whole batch rather than re-acquired per entry. */ confirmAndDeleteSelection(items: SFTPFile[]): Promise; private runBulkDeleteConfirmation; /** Same recursion as the native (non-exported) `SFTPDeleteModalComponent.run()` — no progress UI. Shared by the single-entry and the bulk delete above, entry by entry either way. */ private deleteRecursive; /** Configured columns, in the order declared by `availableColumns`, ignoring ids that no longer exist. */ private columnCache; /** * Cached for its *identity*, not to save the filter. * * This getter feeds an `*ngFor` inside every row and the grid template of * every row. Returning a fresh array each time made Angular re-diff the * columns of each row on every cycle — the single biggest cost on a large * directory. The config's own column list is the invalidation key — the * active locale rides along in it too, since `availableColumns` now * translates its labels on every read: without this, a language change * would sit uninvalidated behind an unchanged column selection and the * header row would keep showing the previous locale's captions. */ get visibleColumns(): SftpColumn[]; /** * Both the header and every row carry this, rather than the grid being * declared once on the container with rows as `display: contents` — a row * that generates no box cannot take a hover background or a bottom border. */ get gridTemplate(): string; private gridCache; isColumnVisible(column: SftpColumn): boolean; isToggleOn(key: string): boolean; toggleDisplayOption(key: string): void; private displayCache; /** * What the list actually renders: `filteredFileList` sorted, and with the * dotfiles dropped when the user asked for it. * * A getter rather than a field kept in sync, because `filteredFileList` is * rebuilt by the inherited — and private — `updateFilteredList()`, with no * hook to piggyback on. Which makes this run on every change detection * pass, hence the cache: sorting a large directory several times a second, * and handing Angular a new array each time for it to diff, is a cost * neither side needs to pay. `updateFilteredList()` always *reassigns* * `filteredFileList` rather than mutating it, so reference equality is a * sound invalidation signal. */ get displayedFiles(): SFTPFile[]; trackByPath(_index: number, item: SFTPFile): string; trackByRow(_index: number, row: SftpRow): string; private rowCache; /** * The rows as the template consumes them: everything formatted up front. * * Recomputed only when the sorted list or the visible columns change, both * of which are compared by reference — `displayedFiles` and * `visibleColumns` each hand back a stable array until something real * changes. `isSelected()` and `isDragPreparing()` stay as calls in the * template on purpose: they are a `Set`/`Map` lookup, and they change * without the list changing at all. */ get rows(): SftpRow[]; /** * How many rows a single growth step adds — first paint included, since * `renderCount` starts at exactly this value. Large enough to fill a tall * sidebar without the sentinel showing up immediately, small enough that * a directory of several thousand entries never lays out more than a * handful of chunks' worth of DOM at once. This bounds the *rendering* * cost only — `readdir` already brought the whole listing over the wire, * `displayedFiles`/`rows` above are computed over all of it, and * scrolling still reaches every entry; nothing here is a cap on what can * be browsed. */ private static readonly RENDER_CHUNK; /** * How many of `rows` the template currently renders. Everything past it * still exists in `rows` — sorted, formatted, zebra-striped, selected, * exactly as if the whole directory were on screen — it simply has no * DOM node yet. That is what keeps `selectRange()` correct without any * change on its part: a Shift+click can only ever land on a row that is * actually rendered, so ranging over `displayedFiles` up to that row is * already bounded to what is visible, and the multi-selection `Set` * itself is never bounded by this at all — entries scrolled past stay * selected. */ private renderCount; /** The observer watching the "load more" sentinel — created and torn down by `loadMoreSentinelRef`'s setter below as the sentinel itself appears and disappears. */ private loadMoreObserver; /** * What the template's `*ngFor` actually loops over. Bounding the DOM * node count is the entire point of this section, so the slice has to be * what is consumed, not `rows` itself — sort, zebra, columns and * selection all stay computed over the full list upstream of this. * `slice()` on an array that never grew past `renderCount` costs nothing * of note, and a short directory never pays for anything past this call. */ get visibleRows(): SftpRow[]; /** Whether more of `rows` exists past what is rendered — gates the sentinel row and, through it, the observer. False for any listing at or under `RENDER_CHUNK`, which is what makes a short directory render exactly as it did before this feature existed: no sentinel, no observer, nothing new. */ get hasMoreRows(): boolean; /** What the sentinel's "… encore X éléments" line reports. */ get remainingRowCount(): number; /** * Back to the first chunk. Called from every action that changes *which* * entries belong on screen — `navigate()` (a real move, or the * "Actualiser" button rereading the same path), the filter box, and the * display toggles that reorder, restrict or resize the listing — rather * than from a single shared cache-invalidation branch on `rows` or * `displayedFiles`, tempting as that looks. Those getters also recompute * on the *background* auto-refresh diff `applyListing()` merges in every * few seconds, which is deliberately built to update quietly in place — * `trackBy` keeps the rows already on screen, and nothing about the view * is supposed to jump (see the doc comment on `applyListing()`). * Resetting the chunk there would silently undo exactly that. */ private resetRenderChunk; /** * `#loadMoreSentinel` lives under `*ngIf='hasMoreRows'`, so a plain * `@ViewChild` field — the pattern `filterInput` uses on the tree — would * go stale the moment the sentinel is added or removed: nothing would * tell this component to start or stop watching it. The setter form * fires on *both* transitions instead, which is what lets the observer's * lifecycle track the sentinel's exactly: built the moment the node * exists, torn down the moment it does not. `filterInput` never needed * this because it is only ever read on demand, never reacted to. */ set loadMoreSentinelRef(ref: ElementRef | undefined); /** Shared by the setter above (every appearance/disappearance of the sentinel) and `ngOnDestroy()` (a component torn down outright, which never runs that setter with `undefined`). */ private teardownLoadMoreObserver; toggleColumn(column: SftpColumn): void; cellValue(column: SftpColumn, item: SFTPFile): string; /** * Date only, no time — the full timestamp lives in the row tooltip. * * The locale is taken from Tabby rather than left to the JS default: * Electron reports en-US whatever the OS says, which would put `7/23/2026` * in an otherwise French panel — and, worse, silently swap day and month. */ shortDate(value: Date): string; fullDate(value: Date): string; /** Permissions as the octal triplet (`755`, `644`), the form actually used when typing a chmod. */ octalMode(item: SFTPFile): string; /** * The `drwxrwxr-x` form, computed here rather than through the inherited * `getModeString()`. * * That one masks `item.mode` against Node's `constants.S_IXUSR`, * `S_IRGRP`, `S_IWOTH` and friends — and **Windows only defines * `S_IRUSR`, `S_IWUSR` and `S_IFDIR`**. The rest come back `undefined`, * `mode & undefined` is 0, and every one of those bits renders as a dash: * a 775 directory displays as `drw-------` on Windows, regardless of its * real permissions. Verified against this very panel on 2026-07-29. */ longMode(item: SFTPFile): string; humanSize(bytes: number): string; typeLabel(item: SFTPFile): string; extension(item: SFTPFile): string; /** * Dotfiles. Dimmed when shown, and shown by default: the `sftpShowHidden` * toggle in the header menu is what decides, `displayedFiles` filtering them * out when it is off. */ isHidden(item: SFTPFile): boolean; /** * Everything the columns had to drop to stay narrow: full name, exact * size, complete timestamp, and the long `drwxr-xr-x` form alongside the * octal one. */ rowTooltip(item: SFTPFile): string; get canGoUp(): boolean; get hasActiveFilter(): boolean; get filterFoundNothing(): boolean; goRoot(): void; get showColumnBorders(): boolean; get showZebra(): boolean; }