import { ConfigService, PartialProfile, PartialProfileGroup, Profile, ProfileGroup } from 'tabby-core'; import { SidebarSnippet, SidebarSnippetSettings } from './configProvider'; import { SidebarPlusI18nService } from './i18n'; /** One row of the variables form — a placeholder the snippets in scope actually read. */ export interface SnippetVariableRow { name: string; /** Default written in the snippet; `undefined` when the variable is required. */ fallback?: string; required: boolean; /** What this very profile/folder answers, empty when it inherits. */ own: string; /** The value that would be substituted right now. */ resolved?: string; /** Where `resolved` comes from — "here", a folder name, "profile", "snippet default" (translated). */ source: string; /** Required and answered nowhere: every snippet using it will refuse to run. */ unanswered: boolean; /** Name of a snippet using it unquoted, when the value would break the command. */ unquotedIn: string | null; } /** * The snippet model: one library, attachments by profile/folder id, and the * variables and behaviours those attachments resolve against. * * A service rather than methods on the tree component because three views need * the same answers — the sidebar (which runs snippets), the modal (which * attaches them and fills their variables) and the settings tab (which writes * the commands). The first draft kept it all in the tree component, and the * settings tab had already grown its own copy of the placeholder regex. */ export declare class SidebarPlusSnippetsService { private config; private i18n; /** * Groups as the sidebar last read them, unfiltered by workspace — needed to * walk a profile up to the root. * * Handed over rather than fetched: `getProfileGroups()` is async and the * tree already holds an unfiltered snapshot, refreshed on every load. A * workspace governs what is *shown*, never what a profile inherits, which * is why the unfiltered one is the right source (decision of 2026-07-28). */ private groups; constructor(config: ConfigService, i18n: SidebarPlusI18nService); useGroups(groups: PartialProfileGroup[]): void; get library(): SidebarSnippet[]; /** How many profiles and folders offer this snippet — what a deletion would take away. */ attachmentCount(snippet: SidebarSnippet): number; /** * Writes a snippet into the library, returning the stored entry. * * An edit lands in the library, so it changes the command everywhere it is * attached. That is what a shared library is for; both editors say so on * screen rather than leaving it to be found out. */ save(draft: SidebarSnippet): Promise; /** * Removes the command outright, and every attachment pointing at it. * * The attachments have to go in the same gesture: an id left behind * resolves to nothing, so the row would simply stop appearing — no error, * and no way to tell it apart from a snippet detached on purpose. */ deleteFromLibrary(snippet: SidebarSnippet): Promise; attachmentsOf(ownerId: string): string[]; /** * Attachments resolved against the library, in attachment order. * * Ids the library no longer holds are dropped rather than rendered empty: * `deleteFromLibrary()` clears them as it goes, so one surviving here means * a hand-edited config, not a case worth an error message. */ attachedSnippets(ownerId: string): SidebarSnippet[]; attach(ownerId: string, snippet: SidebarSnippet): Promise; /** Removes it from this owner only — it stays in the library and wherever else it is attached. */ detach(ownerId: string, snippet: SidebarSnippet): Promise; private writeAttachments; /** The profile itself, then the folder holding it, then up to the root. */ chainForProfile(profile: PartialProfile): string[]; /** A folder and its ancestors, nearest first. */ groupChain(groupId: string | undefined): string[]; groupName(groupId: string): string; /** * Every snippet a profile or folder actually offers: attached along the * chain, deduplicated, minus the ones switched off here. * * The dedup is not cosmetic. Nothing stops the same snippet being attached * to a profile *and* to a folder above it — that is how it read twice in * the modal — and running it twice would be worse than listing it twice. */ snippetsForChain(chain: string[]): SidebarSnippet[]; settingsOf(ownerId: string): SidebarSnippetSettings; /** What one snippet does differently from the others, on this very profile or folder. */ overrideOf(ownerId: string, snippetId: string): SidebarSnippetSettings; /** * First defined answer, `false` when nobody has one. * * At each link of the chain the snippet's own override is asked before the * item's general answer, and only then does the walk move up to the folder * above. That order is what lets a profile run most of its snippets outright * and hold one of them back, while a folder still governs everything it has * not been contradicted on. */ resolveSetting(key: 'autoLaunch' | 'execute', chain: string[], snippetId?: string): boolean; /** Same two-level walk for the launch delay, 0 when nobody answers. */ resolveDelay(chain: string[], snippetId?: string): number; /** `undefined` clears the answer, putting that snippet back to whatever the item says for all of them. */ writeOverride(ownerId: string, snippetId: string, key: keyof SidebarSnippetSettings, value: boolean | number | undefined): Promise; private mutedOf; isMuted(ownerId: string, snippet: SidebarSnippet): boolean; /** Switches an inherited snippet off here, or back on. The folder's attachment is untouched either way. */ toggleMuted(ownerId: string, snippet: SidebarSnippet): Promise; /** `undefined` clears the answer, putting the item back to inheriting. */ writeSetting(ownerId: string, key: keyof SidebarSnippetSettings, value: boolean | number | undefined): Promise; variablesOf(ownerId: string, snippetId: string): Record; /** `null` removes the answer, which is not the same as answering with an empty string. */ writeVariable(ownerId: string, snippetId: string, name: string, value: string | null): Promise; /** * What a profile's connection already answers, without anyone typing it in. * * Bottom of the resolution order, so an explicit variable of the same name * wins. They exist because `{{host}}` is the first thing anyone reaches * for, and restating what the profile already knows would be an odd start. */ implicitVariables(profile: PartialProfile | null): Record; /** * Every variable in scope, nearest definition winning. * * Built from the far end inwards — implicit values, then the root folder, * then down to the profile — so each assignment overwrites the one it is * more specific than. That asymmetry is the point: the command is shared and * lives in the library, while what differs between servers is answered where * the difference is. */ resolveVariables(chain: string[], profile: PartialProfile | null, snippetId: string): Record; /** The placeholders a command reads, required when they carry no fallback. */ parse(command: string): { name: string; fallback?: string; required: boolean; }[]; /** * Substitutes the placeholders, or names what is missing. * * Order: the profile, each folder above it, what the profile already knows, * and last the fallback written in the snippet. Only a placeholder with no * fallback and no answer anywhere counts as missing — and a snippet with one * is refused rather than sent half-expanded. `rm -rf {{dir}}/*` reaching a * shell with its braces intact is not a cosmetic problem. */ expand(snippet: SidebarSnippet, chain: string[], profile: PartialProfile | null): { text: string; missing: string[]; }; /** Required variables this one snippet has no answer for, on this chain. */ missingFor(snippet: SidebarSnippet, chain: string[], profile: PartialProfile | null): string[]; /** * Every unanswered required variable across the snippets a profile offers, * as `snippet → names`. * * Grouped rather than flattened: the same name may be asked by two snippets * and mean two different things, so "{{path}} manque" without saying for * which command would send the user to the wrong field. */ unansweredFor(profile: PartialProfile): { snippet: string; names: string[]; }[]; /** * The variables form: only what the snippets in scope actually read. * * Derived rather than free-form, at the user's request — a profile has no * business being shown a variable no snippet of its own uses. A name used * twice counts as required as soon as one occurrence omits its fallback: * that occurrence refuses to run without an answer. */ variableRows(snippet: SidebarSnippet, chain: string[], profile: PartialProfile | null): SnippetVariableRow[]; /** Where the effective value comes from — so an inherited answer is never mistaken for a local one. */ private variableSource; /** * The snippet, if any, using this variable *unquoted* — checked only when * the value would actually cause trouble. * * Two ways out, and the warning has to accept both, which the first version * did not: quoting the *placeholder in the command* (`cd "{{dir}}"`), or * quoting the *value itself* (`"Mon Dossier"`). The second is checked here * — a value already wrapped in matching quotes, with none inside, reaches * the shell as one word whatever the command looks like. * * The command test counts unescaped quotes before the placeholder: an odd * number means it sits inside a quoted string. A heuristic, not a shell * parser — it would be fooled by a here-doc. Being wrong costs a warning * that should not have appeared, never a command that should not have run. */ private unquotedUseOf; /** A value the user has already wrapped themselves — matching quotes around it, none inside. */ private isSelfQuoted; /** * Drops everything keyed by an id that has just been deleted. * * The library is untouched: the commands outlive the profile they happened * to be written from, which is the point of keeping them apart. */ forget(id: string): void; /** * Carries those same maps over when a folder is given a new id. * * Forgotten, dragging a folder into another would silently strip it of its * snippets — and of every snippet its profiles inherited from it (piège #62). */ migrate(oldId: string, newId: string): void; }