feat(*): svelte 5 panel view sharing the gateway ui, obsidian theme, preview and tests

This commit is contained in:
hh committed 2026-08-29 18:30:31 +02:00
1 parent a1bf4db905
commit 04400c5076
33 files changed
+3378 -1803

No files matched your search

+136
View File
@@ -0,0 +1,136 @@
import type { ViewStateResult, WorkspaceLeaf } from "obsidian";
import { ItemView, TFile } from "obsidian";
import { mount, unmount } from "svelte";
import type BeaverPlugin from "./main";
import { PanelState } from "./state.svelte";
import App from "./ui/app.svelte";
export const VIEW_TYPE_PANEL = "beaver-panel";
interface SavedState {
follow?: unknown;
selected?: unknown;
}
// One panel leaf: a sidedock column, a tab, or the phone screen. Follows
// the active note's ``conversation_id`` unless pinned, and remembers both
// across restarts through the workspace layout.
export class PanelView extends ItemView {
readonly state = new PanelState();
private readonly plugin: BeaverPlugin;
private root: Record<string, unknown> | null = null;
constructor(leaf: WorkspaceLeaf, plugin: BeaverPlugin) {
super(leaf);
this.plugin = plugin;
this.navigation = false;
}
getViewType(): string {
return VIEW_TYPE_PANEL;
}
getDisplayText(): string {
return "Beaver";
}
getIcon(): string {
return "bot";
}
getState(): Record<string, unknown> {
return { follow: this.state.follow, selected: this.state.selected };
}
async setState(state: unknown, result: ViewStateResult): Promise<void> {
const saved = (state ?? {}) as SavedState;
if (typeof saved.follow === "boolean") {
this.state.follow = saved.follow;
}
if (typeof saved.selected === "string" || saved.selected === null) {
this.state.selected = saved.selected;
}
await super.setState(state, result);
}
onOpen(): Promise<void> {
this.contentEl.empty();
this.contentEl.style.padding = "0";
this.contentEl.style.overflow = "hidden";
this.registerEvent(
this.app.workspace.on("file-open", (file) => {
// ``null`` means the active leaf is not a file (this panel got
// focus, say): keep following the last note rather than drop it.
if (file) {
this.follow(file);
}
})
);
this.registerEvent(
this.app.metadataCache.on("changed", (file) => {
// The note we follow just got its conversation_id - typically
// right after the first send. Re-resolve.
if (file.path === this.state.sourcePath) {
this.follow(file);
}
})
);
this.follow(this.app.workspace.getActiveFile());
this.plugin.views.add(this);
this.mountApp();
return Promise.resolve();
}
async onClose(): Promise<void> {
this.plugin.views.delete(this);
await this.unmountApp();
this.contentEl.empty();
}
// The active note changed: remember it, and switch to its conversation
// when following and it has one.
follow(file: TFile | null): void {
if (!(file instanceof TFile)) {
return;
}
this.state.sourcePath = file.path;
if (!this.state.follow) {
return;
}
const id = this.plugin.conversationIdOf(file);
if (id) {
this.state.selected = id;
}
}
show(id: string): void {
this.state.selected = id;
}
// Settings changed: the client is built from them, so rebuild the tree.
async remount(): Promise<void> {
await this.unmountApp();
this.mountApp();
}
private mountApp(): void {
this.root = mount(App, {
props: {
app: this.app,
client: this.plugin.apiClient(),
component: this,
onOpenSettings: () => this.plugin.openSettings(),
state: this.state,
vaultSubpath: () => this.plugin.settings.vaultSubpath,
},
target: this.contentEl,
});
}
private async unmountApp(): Promise<void> {
if (this.root) {
await unmount(this.root);
this.root = null;
}
}
}