Stage 1: extract the skin-agnostic UI layer into app/core/
First step of splitting the UI into two skin trees. All 12 services plus platform.ts, format.ts, file-icons.ts, icons.ts and app.config.ts move to ui/src/app/core/ — the layer both skins will share. This is a pure move: git records all 17 as renames, and no file content changed beyond import paths. The premise holds because `git diff main...feat/7css-skin` touches none of these files — the service layer was already skin-agnostic, so there is nothing to untangle, only to relocate. Components now reach it through a new `@core/*` tsconfig alias rather than a relative path. That is deliberate and load-bearing for the next stage: skin components are about to move under app/skins/<skin>/ at varying depths, and an alias keeps every one of these 67 imports correct wherever its file ends up. `@shared/*` (the bun-side src/shared) already set the precedent. Intra-core imports needed no edits at all — every ./sibling reference among the moved files points at another moved file. Verified: tsc clean, `bun run build:ui` clean (AOT resolves every import and template symbol), and the running window's opaque interior is pixel-identical to a pre-refactor capture. Note on that comparison: the Aero frame and title bar do NOT compare equal between launches, and that is not a regression — .window.glass::before runs at opacity .6, so the chrome composites over whatever desktop is behind the window and shifts by a uniform ~8% brightness. The check therefore diffs the opaque interior (inset 30px sides, 80px top), where anything this refactor could break would show. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,544 @@
|
||||
import { Injectable, computed, effect, inject, signal, type WritableSignal } from '@angular/core';
|
||||
import type { ColumnKey, ConnectionConfig, FileEntry, SavedConnection } from '@shared/types';
|
||||
import { fileKindKey } from './file-icons';
|
||||
import { FocusService } from './focus.service';
|
||||
import { formatMode, isRootPath, joinPath } from './format';
|
||||
import { RpcService } from './rpc.service';
|
||||
import { I18nService } from './i18n.service';
|
||||
import { ToastService } from './toast.service';
|
||||
|
||||
export type Side = 'left' | 'right';
|
||||
/** Any visible column sorts; Name is always available. */
|
||||
export type SortKey = 'name' | ColumnKey;
|
||||
|
||||
/** Display string the text-based columns sort by. */
|
||||
function sortText(entry: FileEntry, key: 'kind' | 'rights' | 'owner' | 'group'): string {
|
||||
if (key === 'kind') return fileKindKey(entry);
|
||||
if (key === 'rights') return formatMode(entry.mode);
|
||||
return (key === 'owner' ? entry.owner : entry.group) ?? '';
|
||||
}
|
||||
|
||||
interface PanelDeps {
|
||||
rpc: RpcService;
|
||||
toast: ToastService;
|
||||
}
|
||||
|
||||
export class PanelState {
|
||||
readonly sessionId = signal('local');
|
||||
/** Filesystem identity ("local" or resolvedIp:port) — self-copy guards use this. */
|
||||
readonly fsKey = signal('local');
|
||||
readonly title = signal('Local');
|
||||
readonly connecting = signal(false);
|
||||
/** Name shown in the "Connecting to X…" placeholder while connecting. */
|
||||
readonly connectingTo = signal<string | null>(null);
|
||||
readonly cwd = signal('');
|
||||
readonly entries = signal<FileEntry[]>([]);
|
||||
readonly loading = signal(false);
|
||||
/** True once an in-flight load has taken >300ms — drives the dim overlay. */
|
||||
readonly slowLoad = signal(false);
|
||||
/** Bumping this discards the result of any in-flight connect (cancel/supersede). */
|
||||
connectToken = 0;
|
||||
/** True once the user navigated or connected — a "pristine" panel is still at local home. */
|
||||
touched = false;
|
||||
|
||||
/** Input on this panel is ignored while it's connecting or loading. */
|
||||
readonly busy = computed(() => this.connecting() || this.loading());
|
||||
readonly sortKey = signal<SortKey>('name');
|
||||
readonly sortAsc = signal(true);
|
||||
readonly cursor = signal(0);
|
||||
readonly selected = signal<ReadonlySet<string>>(new Set());
|
||||
/** Type-to-filter query; entries not matching are hidden (".." always stays). */
|
||||
readonly filter = signal('');
|
||||
|
||||
/** Directories first, then the active sort column. ".." (parent) is pinned on top. */
|
||||
readonly sorted = computed(() => {
|
||||
const query = this.filter().toLowerCase();
|
||||
let list = [...this.entries()];
|
||||
if (query) {
|
||||
list = list.filter((e) => e.name.toLowerCase().includes(query));
|
||||
}
|
||||
const key = this.sortKey();
|
||||
const dir = this.sortAsc() ? 1 : -1;
|
||||
list.sort((a, b) => {
|
||||
const aDir = a.type === 'dir';
|
||||
const bDir = b.type === 'dir';
|
||||
if (aDir !== bDir) return aDir ? -1 : 1;
|
||||
let cmp = 0;
|
||||
if (key === 'size') cmp = a.size - b.size;
|
||||
else if (key === 'mtime') cmp = a.mtime - b.mtime;
|
||||
else if (key !== 'name') cmp = sortText(a, key).localeCompare(sortText(b, key));
|
||||
if (cmp === 0) {
|
||||
cmp = a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' });
|
||||
if (key === 'name') return cmp * dir;
|
||||
}
|
||||
return cmp * dir;
|
||||
});
|
||||
const cwd = this.cwd();
|
||||
if (cwd && !isRootPath(cwd)) {
|
||||
list.unshift({ name: '..', path: joinPath(cwd, '..'), type: 'dir', size: 0, mtime: 0, mode: 0 });
|
||||
}
|
||||
return list;
|
||||
});
|
||||
|
||||
readonly selectedEntries = computed(() => {
|
||||
const names = this.selected();
|
||||
return this.sorted().filter((e) => names.has(e.name));
|
||||
});
|
||||
|
||||
constructor(private deps: PanelDeps) {}
|
||||
|
||||
async load(path: string | null): Promise<void> {
|
||||
if (path !== null) this.touched = true;
|
||||
this.loading.set(true);
|
||||
const slowTimer = setTimeout(() => this.slowLoad.set(true), 300);
|
||||
try {
|
||||
const res = await this.deps.rpc.request.listDir({ sessionId: this.sessionId(), path });
|
||||
if (!res.ok) {
|
||||
this.deps.toast.error(res.error);
|
||||
return;
|
||||
}
|
||||
this.cwd.set(res.path);
|
||||
this.entries.set(res.entries);
|
||||
this.cursor.set(0);
|
||||
this.selected.set(new Set());
|
||||
this.filter.set('');
|
||||
} finally {
|
||||
clearTimeout(slowTimer);
|
||||
this.slowLoad.set(false);
|
||||
this.loading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
private refreshing = false;
|
||||
|
||||
/**
|
||||
* Reload cwd, keeping cursor position and selection by name. `silent` (used by
|
||||
* auto-refresh) skips the loading/slowLoad flags — so no spinner, no dim
|
||||
* overlay, input never blocked — and touches no DOM, so scroll stays put.
|
||||
*/
|
||||
async refresh(opts?: { silent?: boolean }): Promise<void> {
|
||||
const silent = opts?.silent ?? false;
|
||||
// a silent refresh must never fight the user or overlap itself
|
||||
if (silent && (this.refreshing || this.busy())) return;
|
||||
this.refreshing = true;
|
||||
// snapshot what we're listing; a silent refresh has no busy-block, so the
|
||||
// user can navigate mid-flight — discard the result if they did
|
||||
const forSession = this.sessionId();
|
||||
const forDir = this.cwd();
|
||||
const keepCursorName = this.sorted()[this.cursor()]?.name;
|
||||
const keepSelected = this.selected();
|
||||
if (!silent) this.loading.set(true);
|
||||
const slowTimer = silent ? null : setTimeout(() => this.slowLoad.set(true), 300);
|
||||
try {
|
||||
const res = await this.deps.rpc.request.listDir({
|
||||
sessionId: forSession,
|
||||
path: forDir,
|
||||
});
|
||||
if (this.sessionId() !== forSession || this.cwd() !== forDir) return;
|
||||
if (!res.ok) {
|
||||
if (!silent) this.deps.toast.error(res.error);
|
||||
return;
|
||||
}
|
||||
this.cwd.set(res.path);
|
||||
this.entries.set(res.entries);
|
||||
const names = new Set(res.entries.map((e) => e.name));
|
||||
this.selected.set(new Set([...keepSelected].filter((n) => names.has(n))));
|
||||
const idx = this.sorted().findIndex((e) => e.name === keepCursorName);
|
||||
this.cursor.set(idx >= 0 ? idx : Math.min(this.cursor(), Math.max(0, this.sorted().length - 1)));
|
||||
} finally {
|
||||
if (slowTimer) clearTimeout(slowTimer);
|
||||
if (!silent) {
|
||||
this.slowLoad.set(false);
|
||||
this.loading.set(false);
|
||||
}
|
||||
this.refreshing = false;
|
||||
}
|
||||
}
|
||||
|
||||
open(entry: FileEntry): void {
|
||||
if (entry.type === 'file') return;
|
||||
// symlinks: try to open as a dir; the error toast covers file links
|
||||
void this.load(entry.path);
|
||||
}
|
||||
|
||||
up(): void {
|
||||
if (!this.cwd()) return;
|
||||
void this.load(joinPath(this.cwd(), '..'));
|
||||
}
|
||||
|
||||
/** Update the filter and park the cursor on the first real match. */
|
||||
setFilter(query: string): void {
|
||||
this.filter.set(query);
|
||||
const first = this.sorted().findIndex((e) => e.name !== '..');
|
||||
this.cursor.set(first >= 0 ? first : 0);
|
||||
}
|
||||
|
||||
toggleSort(key: SortKey): void {
|
||||
if (this.sortKey() === key) {
|
||||
this.sortAsc.update((v) => !v);
|
||||
} else {
|
||||
this.sortKey.set(key);
|
||||
this.sortAsc.set(true);
|
||||
}
|
||||
}
|
||||
|
||||
moveCursor(delta: number): void {
|
||||
const max = this.sorted().length - 1;
|
||||
if (max < 0) return;
|
||||
this.cursor.set(Math.max(0, Math.min(max, this.cursor() + delta)));
|
||||
}
|
||||
|
||||
toggleSelect(name: string): void {
|
||||
if (name === '..') return;
|
||||
this.selected.update((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(name)) next.delete(name);
|
||||
else next.add(name);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
selectRange(fromIndex: number, toIndex: number): void {
|
||||
const [lo, hi] = fromIndex <= toIndex ? [fromIndex, toIndex] : [toIndex, fromIndex];
|
||||
const names = this.sorted()
|
||||
.slice(lo, hi + 1)
|
||||
.map((e) => e.name)
|
||||
.filter((n) => n !== '..');
|
||||
this.selected.update((prev) => new Set([...prev, ...names]));
|
||||
}
|
||||
|
||||
selectAll(): void {
|
||||
this.selected.set(new Set(this.sorted().map((e) => e.name).filter((n) => n !== '..')));
|
||||
}
|
||||
|
||||
clearSelection(): void {
|
||||
if (this.selected().size) this.selected.set(new Set());
|
||||
}
|
||||
}
|
||||
|
||||
/** One tab: an independent left/right panel pair. */
|
||||
export interface Workspace {
|
||||
id: number;
|
||||
left: PanelState;
|
||||
right: PanelState;
|
||||
activeSide: WritableSignal<Side>;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class PanelsService {
|
||||
private readonly i18n = inject(I18nService);
|
||||
readonly workspaces = signal<Workspace[]>([]);
|
||||
readonly activeWorkspaceId = signal(0);
|
||||
readonly activeWorkspace = computed(() => {
|
||||
const list = this.workspaces();
|
||||
return list.find((w) => w.id === this.activeWorkspaceId()) ?? list[0];
|
||||
});
|
||||
private readonly focus = inject(FocusService);
|
||||
/** Bumped to tell the active panel to grab keyboard focus again (e.g. after a dialog closes). */
|
||||
readonly focusTick = this.focus.tick;
|
||||
|
||||
private nextWorkspaceId = 1;
|
||||
|
||||
constructor(
|
||||
private rpc: RpcService,
|
||||
private toast: ToastService,
|
||||
) {
|
||||
this.newWorkspace();
|
||||
|
||||
// a session died on its own — reset every panel bound to it, in any tab
|
||||
this.rpc.onSessionClosed((event) => {
|
||||
for (const workspace of this.workspaces()) {
|
||||
for (const panel of [workspace.left, workspace.right]) {
|
||||
if (panel.sessionId() === event.sessionId) {
|
||||
this.toast.error(
|
||||
this.i18n.t('toasts.disconnectedReason', {
|
||||
title: panel.title(),
|
||||
reason: event.reason,
|
||||
}),
|
||||
);
|
||||
this.resetToLocal(panel);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// a watched local dir changed, or an editor saved a file — silently
|
||||
// refresh every panel showing that directory (no spinner, view preserved)
|
||||
this.rpc.onPathRefresh(({ sessionId, dir }) => {
|
||||
for (const panel of this.allPanels()) {
|
||||
if (panel.sessionId() === sessionId && panel.cwd() === dir) {
|
||||
void panel.refresh({ silent: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.setupAutoRefresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-refresh the active tab's panels without disturbing the view. Local
|
||||
* panels are watched instantly via fs.watch (backend pushes pathRefresh);
|
||||
* remote panels have no change notification, so they're polled. Only the
|
||||
* active tab is watched/polled; switching tabs refreshes the new one at once.
|
||||
*/
|
||||
private setupAutoRefresh(): void {
|
||||
// REMOTE_POLL_MS: also the cadence a future "auto-refresh interval" setting
|
||||
// would drive (see the commented breadcrumbs in settings).
|
||||
const REMOTE_POLL_MS = 3000;
|
||||
setInterval(() => {
|
||||
const ws = this.activeWorkspace();
|
||||
if (!ws) return;
|
||||
for (const panel of [ws.left, ws.right]) {
|
||||
if (panel.sessionId() !== 'local') void panel.refresh({ silent: true });
|
||||
}
|
||||
}, REMOTE_POLL_MS);
|
||||
|
||||
// register the active tab's LOCAL dirs for fs.watch; re-runs when the active
|
||||
// tab changes or either panel navigates/(dis)connects
|
||||
effect(() => {
|
||||
const ws = this.activeWorkspace();
|
||||
const dirs = ws
|
||||
? [ws.left, ws.right]
|
||||
.filter((p) => p.sessionId() === 'local' && p.cwd())
|
||||
.map((p) => p.cwd())
|
||||
: [];
|
||||
void this.rpc.request.setWatchedDirs({ dirs: [...new Set(dirs)] });
|
||||
});
|
||||
|
||||
// switching tabs shows fresh data immediately instead of waiting a poll tick
|
||||
let lastActive = this.activeWorkspaceId();
|
||||
effect(() => {
|
||||
const id = this.activeWorkspaceId();
|
||||
if (id === lastActive) return;
|
||||
lastActive = id;
|
||||
const ws = this.activeWorkspace();
|
||||
if (ws) for (const panel of [ws.left, ws.right]) void panel.refresh({ silent: true });
|
||||
});
|
||||
}
|
||||
|
||||
// ---- panels of the active workspace ----
|
||||
|
||||
get(side: Side): PanelState {
|
||||
const workspace = this.activeWorkspace();
|
||||
return side === 'left' ? workspace.left : workspace.right;
|
||||
}
|
||||
|
||||
activeSide(): Side {
|
||||
return this.activeWorkspace().activeSide();
|
||||
}
|
||||
|
||||
setActiveSide(side: Side): void {
|
||||
this.activeWorkspace().activeSide.set(side);
|
||||
}
|
||||
|
||||
active(): PanelState {
|
||||
return this.get(this.activeSide());
|
||||
}
|
||||
|
||||
otherSide(side: Side): Side {
|
||||
return side === 'left' ? 'right' : 'left';
|
||||
}
|
||||
|
||||
other(side: Side): PanelState {
|
||||
return this.get(this.otherSide(side));
|
||||
}
|
||||
|
||||
/** Every panel across every workspace (for session-wide updates). */
|
||||
allPanels(): PanelState[] {
|
||||
return this.workspaces().flatMap((w) => [w.left, w.right]);
|
||||
}
|
||||
|
||||
refocus(): void {
|
||||
this.focus.bump();
|
||||
}
|
||||
|
||||
// ---- workspace (tab) management ----
|
||||
|
||||
newWorkspace(): void {
|
||||
const deps = { rpc: this.rpc, toast: this.toast };
|
||||
const workspace: Workspace = {
|
||||
id: this.nextWorkspaceId++,
|
||||
left: new PanelState(deps),
|
||||
right: new PanelState(deps),
|
||||
activeSide: signal<Side>('left'),
|
||||
};
|
||||
this.workspaces.update((list) => [...list, workspace]);
|
||||
this.activeWorkspaceId.set(workspace.id);
|
||||
if (this.rpc.available) {
|
||||
// may be called during template rendering, where signal writes inside
|
||||
// load() are forbidden — defer past the render pass
|
||||
queueMicrotask(() => {
|
||||
void workspace.left.load(null);
|
||||
void workspace.right.load(null);
|
||||
});
|
||||
}
|
||||
this.refocus();
|
||||
}
|
||||
|
||||
/** Close a tab; its remote sessions are disconnected. The last tab stays. */
|
||||
closeWorkspace(id: number): void {
|
||||
const list = this.workspaces();
|
||||
if (list.length <= 1) return;
|
||||
const index = list.findIndex((w) => w.id === id);
|
||||
if (index < 0) return;
|
||||
const workspace = list[index];
|
||||
|
||||
for (const panel of [workspace.left, workspace.right]) {
|
||||
const sessionId = panel.sessionId();
|
||||
if (sessionId !== 'local') {
|
||||
void this.rpc.request.disconnect({ sessionId });
|
||||
}
|
||||
}
|
||||
|
||||
this.workspaces.update((l) => l.filter((w) => w.id !== id));
|
||||
if (this.activeWorkspaceId() === id) {
|
||||
const neighbor = list[index + 1] ?? list[index - 1];
|
||||
this.activeWorkspaceId.set(neighbor.id);
|
||||
}
|
||||
this.refocus();
|
||||
}
|
||||
|
||||
setActiveWorkspace(id: number): void {
|
||||
if (this.activeWorkspaceId() !== id) {
|
||||
this.activeWorkspaceId.set(id);
|
||||
this.refocus();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a stack: one tab per connection, left panel connecting (right stays
|
||||
* local, jumping to the connection's localDir when set). The first
|
||||
* connection reuses the current tab if it's pristine (local/local, never
|
||||
* navigated) instead of leaving a useless Local tab behind.
|
||||
*/
|
||||
openStack(connections: SavedConnection[]): void {
|
||||
for (const [index, connection] of connections.entries()) {
|
||||
if (index > 0 || !this.workspaceIsPristine(this.activeWorkspace())) {
|
||||
this.newWorkspace();
|
||||
}
|
||||
// connectPanel resolves the panel synchronously (before awaiting), so
|
||||
// firing these back-to-back binds each connection to its own tab.
|
||||
// Remote goes right, local stays left — the commander convention.
|
||||
void this.connectPanel('right', connection, connection.name, connection.localDir);
|
||||
}
|
||||
}
|
||||
|
||||
private workspaceIsPristine(workspace: Workspace): boolean {
|
||||
return [workspace.left, workspace.right].every(
|
||||
(p) => p.sessionId() === 'local' && !p.touched && !p.connecting(),
|
||||
);
|
||||
}
|
||||
|
||||
/** Reorder a tab (drag & drop). Order is purely visual; ids stay stable. */
|
||||
moveWorkspace(id: number, toIndex: number): void {
|
||||
this.workspaces.update((list) => {
|
||||
const fromIndex = list.findIndex((w) => w.id === id);
|
||||
if (fromIndex < 0) return list;
|
||||
const next = [...list];
|
||||
const [moved] = next.splice(fromIndex, 1);
|
||||
next.splice(Math.max(0, Math.min(next.length, toIndex)), 0, moved);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
cycleWorkspace(delta: 1 | -1): void {
|
||||
const list = this.workspaces();
|
||||
if (list.length < 2) return;
|
||||
const index = list.findIndex((w) => w.id === this.activeWorkspaceId());
|
||||
const next = list[(index + delta + list.length) % list.length];
|
||||
this.setActiveWorkspace(next.id);
|
||||
}
|
||||
|
||||
/** Jump to a tab by visual position; negative counts from the end (⌘9 = last). */
|
||||
goToWorkspace(index: number): void {
|
||||
const target = this.workspaces().at(index);
|
||||
if (target) this.setActiveWorkspace(target.id);
|
||||
}
|
||||
|
||||
/** Tab label: "Local" for a local/local pair, else the remote titles. */
|
||||
workspaceName(workspace: Workspace): string {
|
||||
const remoteTitles = [workspace.left, workspace.right]
|
||||
.filter((p) => p.sessionId() !== 'local')
|
||||
.map((p) => p.title());
|
||||
return remoteTitles.length ? remoteTitles.join(' ⇄ ') : this.i18n.t('common.local');
|
||||
}
|
||||
|
||||
workspaceIsRemote(workspace: Workspace): boolean {
|
||||
return workspace.left.sessionId() !== 'local' || workspace.right.sessionId() !== 'local';
|
||||
}
|
||||
|
||||
// ---- session binding ----
|
||||
|
||||
/**
|
||||
* Connect a panel to a server; replaces (and disconnects) its previous remote
|
||||
* session. When the connection has a localDir and the workspace's other panel
|
||||
* is local and untouched, that panel jumps to the localDir.
|
||||
*/
|
||||
async connectPanel(
|
||||
side: Side,
|
||||
config: ConnectionConfig,
|
||||
title: string,
|
||||
localDir?: string | null,
|
||||
): Promise<boolean> {
|
||||
const workspace = this.activeWorkspace();
|
||||
const panel = workspace[side];
|
||||
const token = ++panel.connectToken;
|
||||
panel.connecting.set(true);
|
||||
panel.connectingTo.set(title);
|
||||
try {
|
||||
const res = await this.rpc.request.connect({ config });
|
||||
if (panel.connectToken !== token) {
|
||||
// cancelled or superseded while waiting — clean up the orphan session
|
||||
if (res.ok) void this.rpc.request.disconnect({ sessionId: res.sessionId });
|
||||
return false;
|
||||
}
|
||||
if (!res.ok) {
|
||||
this.toast.error(`${title}: ${res.error}`);
|
||||
return false;
|
||||
}
|
||||
const previous = panel.sessionId();
|
||||
if (previous !== 'local') {
|
||||
void this.rpc.request.disconnect({ sessionId: previous });
|
||||
}
|
||||
panel.sessionId.set(res.sessionId);
|
||||
panel.fsKey.set(res.fsKey);
|
||||
panel.title.set(title);
|
||||
panel.touched = true;
|
||||
const other = workspace[side === 'left' ? 'right' : 'left'];
|
||||
if (localDir && other.sessionId() === 'local' && !other.touched) {
|
||||
void other.load(localDir);
|
||||
}
|
||||
await panel.load(config.initialPath ?? res.homeDir);
|
||||
return true;
|
||||
} finally {
|
||||
if (panel.connectToken === token) {
|
||||
panel.connecting.set(false);
|
||||
panel.connectingTo.set(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Abort waiting on an in-flight connect; the panel keeps its previous state. */
|
||||
cancelConnect(side: Side): void {
|
||||
const panel = this.get(side);
|
||||
if (!panel.connecting()) return;
|
||||
panel.connectToken++;
|
||||
panel.connecting.set(false);
|
||||
panel.connectingTo.set(null);
|
||||
}
|
||||
|
||||
async disconnectPanel(side: Side): Promise<void> {
|
||||
const panel = this.get(side);
|
||||
const sessionId = panel.sessionId();
|
||||
if (sessionId === 'local') return;
|
||||
await this.rpc.request.disconnect({ sessionId });
|
||||
this.resetToLocal(panel);
|
||||
}
|
||||
|
||||
private resetToLocal(panel: PanelState): void {
|
||||
panel.sessionId.set('local');
|
||||
panel.fsKey.set('local');
|
||||
panel.title.set('Local');
|
||||
void panel.load(null);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user