Compare commits
5
Commits
a04ad0094f
..
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
91699a382e | ||
|
|
43d1e1a4a3 | ||
|
|
223560c8da | ||
|
|
70cf985aa1 | ||
|
|
413d68fc87 |
No files matched your search
@@ -7,6 +7,10 @@ Two things:
|
|||||||
- **The panel**: Beaver's conversations next to your notes - the master thread, branches, deep chats - with the live tree of tool calls and subagents, the composer, and the same actions the admin has (branch with a seed, return a branch to Telegram or hide it, the memory flag, close with a digest). A narrow sidedock column, a full tab, or the phone screen. It follows the active note's `conversation_id` unless you pin it.
|
- **The panel**: Beaver's conversations next to your notes - the master thread, branches, deep chats - with the live tree of tool calls and subagents, the composer, and the same actions the admin has (branch with a seed, return a branch to Telegram or hide it, the memory flag, close with a digest). A narrow sidedock column, a full tab, or the phone screen. It follows the active note's `conversation_id` unless you pin it.
|
||||||
- **Send the current note** to Beaver's markdown frontend (`POST /chat/stream`) and stream the agent's reply back into the buffer.
|
- **Send the current note** to Beaver's markdown frontend (`POST /chat/stream`) and stream the agent's reply back into the buffer.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
- **Beaver: Open panel in a tab** - the wide layout with the conversation rail on the left (also the ribbon icon).
|
- **Beaver: Open panel in a tab** - the wide layout with the conversation rail on the left (also the ribbon icon).
|
||||||
@@ -20,7 +24,7 @@ The UI is `beaver-gateway/ui/src/lib/panel`, compiled into this plugin by `esbui
|
|||||||
|
|
||||||
Theme: `src/tailwind.css` maps the panel's tokens onto Obsidian's variables on `.beaver-root`, exactly as beaver-calendar does with `.bcal-root` - the app's font, accent, radii and the current theme, light or dark. Menus and dialogs portal to a `.beaver-root.beaver-portal` layer on `body`, above the sidedocks.
|
Theme: `src/tailwind.css` maps the panel's tokens onto Obsidian's variables on `.beaver-root`, exactly as beaver-calendar does with `.bcal-root` - the app's font, accent, radii and the current theme, light or dark. Menus and dialogs portal to a `.beaver-root.beaver-portal` layer on `body`, above the sidedocks.
|
||||||
|
|
||||||
Markdown in the thread goes through Obsidian's `MarkdownRenderer`: `[[links]]` open the note, external links open the browser. A deep chat's row offers **Open note** (its markdown binding, under the vault subpath).
|
Markdown in the thread goes through Obsidian's `MarkdownRenderer`: `[[links]]` open the note, external links open the browser. A deep chat's row offers **Open note** (its markdown binding, under the chats folder).
|
||||||
|
|
||||||
Layout: below 48rem of panel width the conversation switcher sits in the header (kind, title, live dot, follow toggle); wider than that the grouped rail (master, branches, deep chats, jobs) sits on the left. Right-click a row for its actions; on touch the `…` button is always visible. `↑`/`↓` walk the rows, `/` jumps to the search, `⌘↩` sends.
|
Layout: below 48rem of panel width the conversation switcher sits in the header (kind, title, live dot, follow toggle); wider than that the grouped rail (master, branches, deep chats, jobs) sits on the left. Right-click a row for its actions; on touch the `…` button is always visible. `↑`/`↓` walk the rows, `/` jumps to the search, `⌘↩` sends.
|
||||||
|
|
||||||
@@ -29,7 +33,7 @@ Layout: below 48rem of panel width the conversation switcher sits in the header
|
|||||||
- **Base URL** - markdown frontend root, e.g. `https://host/md`.
|
- **Base URL** - markdown frontend root, e.g. `https://host/md`.
|
||||||
- **API origin** - where `/api/…` lives; empty derives it from the base URL (`…/md` → `…`).
|
- **API origin** - where `/api/…` lives; empty derives it from the base URL (`…/md` → `…`).
|
||||||
- **Bearer token** - `messages` scope for sending, `api` scope for the panel (or a `*` bootstrap token).
|
- **Bearer token** - `messages` scope for sending, `api` scope for the panel (or a `*` bootstrap token).
|
||||||
- **Vault subpath** - the folder in this vault that maps to the gateway's vault root.
|
- **Chats folder** - the folder in this vault where the deep chats live, the one the gateway's markdown frontend writes to. Paths the gateway hands out are relative to it.
|
||||||
|
|
||||||
Changing settings remounts open panels.
|
Changing settings remounts open panels.
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 53 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 91 KiB |
+1
-1
@@ -54,11 +54,11 @@ async function main(): Promise<void> {
|
|||||||
mount(App, {
|
mount(App, {
|
||||||
props: {
|
props: {
|
||||||
app: app as never,
|
app: app as never,
|
||||||
|
chatsFolder: () => "💬 чаты",
|
||||||
client: scene === "offline" ? null : client,
|
client: scene === "offline" ? null : client,
|
||||||
component: new Component() as never,
|
component: new Component() as never,
|
||||||
onOpenSettings: () => console.log("settings"),
|
onOpenSettings: () => console.log("settings"),
|
||||||
state,
|
state,
|
||||||
vaultSubpath: () => "💬 чаты",
|
|
||||||
},
|
},
|
||||||
target: document.getElementById("app") as HTMLElement,
|
target: document.getElementById("app") as HTMLElement,
|
||||||
});
|
});
|
||||||
|
|||||||
+17
-1
@@ -223,7 +223,12 @@ function stamped(messages: HistoryMessage[]): HistoryMessage[] {
|
|||||||
|
|
||||||
const HISTORY: Record<string, HistoryMessage[]> = {
|
const HISTORY: Record<string, HistoryMessage[]> = {
|
||||||
[MASTER]: [
|
[MASTER]: [
|
||||||
{ content: "с утра: дневник приехал? что в хендауте", role: "user" },
|
{
|
||||||
|
content: "с утра: дневник приехал? что в хендауте",
|
||||||
|
role: "user",
|
||||||
|
service:
|
||||||
|
"[envelope - background signal, not a message; react only if it bears on the question]\ntime: 2026-08-29 06:04 (Warsaw)\nvault, changed since 05:09: 📅 дни/2026-08-29.md (+4)\n--- 📅 дни/2026-08-29.md, added lines only ---\n+ # планы\n+ просрочены:\n+ ```tasks\n+ not done\n+ 06:04 - пожалуй пойду спать",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
content:
|
content:
|
||||||
"Приехал. В [[2026-08-28|хендауте]] два хвоста: **бластер** ждёт выбора модели, и **ufw на dell** - порт 62990 снаружи молчит. Ветка по ufw открыта, см. [[ufw на dell]].",
|
"Приехал. В [[2026-08-28|хендауте]] два хвоста: **бластер** ждёт выбора модели, и **ufw на dell** - порт 62990 снаружи молчит. Ветка по ufw открыта, см. [[ufw на dell]].",
|
||||||
@@ -232,6 +237,11 @@ const HISTORY: Record<string, HistoryMessage[]> = {
|
|||||||
{ content: "ок, гони ufw", role: "user" },
|
{ content: "ок, гони ufw", role: "user" },
|
||||||
{
|
{
|
||||||
content: [
|
content: [
|
||||||
|
{
|
||||||
|
thinking:
|
||||||
|
"The firewall rule and the proxy config can disagree in two ways: the port is closed, or nothing listens behind it. Reading the Caddy site first tells me which port the outside is supposed to reach at all.",
|
||||||
|
type: "thinking",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
text: "Смотрю правила через `ssh dell` и сверяю с `site.caddy`.",
|
text: "Смотрю правила через `ssh dell` и сверяю с `site.caddy`.",
|
||||||
type: "text",
|
type: "text",
|
||||||
@@ -242,6 +252,12 @@ const HISTORY: Record<string, HistoryMessage[]> = {
|
|||||||
name: "Bash",
|
name: "Bash",
|
||||||
type: "tool_use",
|
type: "tool_use",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "tu_say",
|
||||||
|
input: { text: "Нашёл: снаружи должен отвечать 443, не 62990." },
|
||||||
|
name: "mcp__gateway__say",
|
||||||
|
type: "tool_use",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
/**
|
||||||
|
* How much of a leaf's bottom is covered by something the operator cannot
|
||||||
|
* move: Obsidian's desktop status bar, a phone's navbar (which paints over a
|
||||||
|
* leaf rather than shortening it), and the software keyboard, which shrinks
|
||||||
|
* the visual viewport while the layout keeps its full height.
|
||||||
|
*
|
||||||
|
* Pure on purpose - the view feeds it live rectangles, the tests feed it the
|
||||||
|
* numbers a phone actually reports.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface Rect {
|
||||||
|
bottom: number;
|
||||||
|
height: number;
|
||||||
|
left: number;
|
||||||
|
right: number;
|
||||||
|
top: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Visible {
|
||||||
|
/** `visualViewport.height`: what is left once the keyboard is up. */
|
||||||
|
height: number;
|
||||||
|
/** `visualViewport.offsetTop`: where the visible area starts. */
|
||||||
|
offsetTop: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function insetFor(
|
||||||
|
leaf: Rect,
|
||||||
|
bars: Rect[],
|
||||||
|
visible: Visible | null
|
||||||
|
): number {
|
||||||
|
if (leaf.height <= 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
let overlap = 0;
|
||||||
|
for (const bar of bars) {
|
||||||
|
// A bar that does not sit over this leaf horizontally covers nothing of
|
||||||
|
// it - the desktop status bar next to a left-hand sidedock, say.
|
||||||
|
if (bar.height > 0 && bar.left < leaf.right && bar.right > leaf.left) {
|
||||||
|
overlap = Math.max(overlap, leaf.bottom - bar.top);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (visible) {
|
||||||
|
const floor = visible.offsetTop + visible.height;
|
||||||
|
overlap = Math.max(overlap, leaf.bottom - floor);
|
||||||
|
}
|
||||||
|
return Math.max(0, Math.round(overlap));
|
||||||
|
}
|
||||||
+15
-6
@@ -230,7 +230,16 @@ export default class BeaverPlugin extends Plugin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async loadSettings(): Promise<void> {
|
async loadSettings(): Promise<void> {
|
||||||
this.settings = { ...DEFAULT_SETTINGS, ...(await this.loadData()) };
|
const stored = ((await this.loadData()) ??
|
||||||
|
{}) as Partial<BeaverSettings> & {
|
||||||
|
vaultSubpath?: string;
|
||||||
|
};
|
||||||
|
// The setting used to be called "vault subpath"; carry the value over.
|
||||||
|
const { vaultSubpath, ...rest } = stored;
|
||||||
|
this.settings = { ...DEFAULT_SETTINGS, ...rest };
|
||||||
|
if (vaultSubpath && !rest.chatsFolder) {
|
||||||
|
this.settings.chatsFolder = vaultSubpath;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async saveSettings(): Promise<void> {
|
async saveSettings(): Promise<void> {
|
||||||
@@ -431,16 +440,16 @@ export default class BeaverPlugin extends Plugin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private gatewayFilename(file: TFile): string | null {
|
private gatewayFilename(file: TFile): string | null {
|
||||||
const subpath = this.settings.vaultSubpath;
|
const folder = this.settings.chatsFolder;
|
||||||
if (!subpath) {
|
if (!folder) {
|
||||||
return file.path;
|
return file.path;
|
||||||
}
|
}
|
||||||
const prefix = `${subpath}/`;
|
const prefix = `${folder}/`;
|
||||||
if (file.path === subpath || file.path.startsWith(prefix)) {
|
if (file.path === folder || file.path.startsWith(prefix)) {
|
||||||
return file.path.slice(prefix.length);
|
return file.path.slice(prefix.length);
|
||||||
}
|
}
|
||||||
new Notice(
|
new Notice(
|
||||||
`Beaver: file ${file.path} is outside the configured vault subpath (${subpath})`,
|
`Beaver: file ${file.path} is outside the chats folder (${folder})`,
|
||||||
8000
|
8000
|
||||||
);
|
);
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
+11
-8
@@ -5,18 +5,20 @@ import type BeaverPlugin from "./main";
|
|||||||
export interface BeaverSettings {
|
export interface BeaverSettings {
|
||||||
apiBaseUrl: string;
|
apiBaseUrl: string;
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
|
// Folder in this vault where the deep chats live: the gateway's markdown
|
||||||
|
// frontend reads and writes there, so paths it hands out are relative to it.
|
||||||
|
chatsFolder: string;
|
||||||
token: string;
|
token: string;
|
||||||
vaultSubpath: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_SETTINGS: BeaverSettings = {
|
export const DEFAULT_SETTINGS: BeaverSettings = {
|
||||||
apiBaseUrl: "",
|
apiBaseUrl: "",
|
||||||
baseUrl: "http://localhost:62993",
|
baseUrl: "http://localhost:62993",
|
||||||
|
chatsFolder: "",
|
||||||
token: "",
|
token: "",
|
||||||
vaultSubpath: "",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function normalizeSubpath(raw: string): string {
|
export function normalizeFolder(raw: string): string {
|
||||||
return raw.trim().replace(/^\/+|\/+$/g, "");
|
return raw.trim().replace(/^\/+|\/+$/g, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,17 +95,18 @@ export class BeaverSettingsTab extends PluginSettingTab {
|
|||||||
});
|
});
|
||||||
|
|
||||||
new Setting(containerEl)
|
new Setting(containerEl)
|
||||||
.setName("Vault subpath")
|
.setName("Chats folder")
|
||||||
.setDesc(
|
.setDesc(
|
||||||
"Folder in this Obsidian vault that maps to the gateway's vault root. " +
|
"Folder in this vault where the deep chats live - the one the gateway's " +
|
||||||
"Leave empty if the two vault roots match. Example: `💬 чаты`."
|
"markdown frontend writes to. Leave empty if the chats sit at the vault root. " +
|
||||||
|
"Example: `💬 чаты`."
|
||||||
)
|
)
|
||||||
.addText((text) =>
|
.addText((text) =>
|
||||||
text
|
text
|
||||||
.setPlaceholder("")
|
.setPlaceholder("")
|
||||||
.setValue(this.plugin.settings.vaultSubpath)
|
.setValue(this.plugin.settings.chatsFolder)
|
||||||
.onChange(async (value) => {
|
.onChange(async (value) => {
|
||||||
this.plugin.settings.vaultSubpath = normalizeSubpath(value);
|
this.plugin.settings.chatsFolder = normalizeFolder(value);
|
||||||
await this.plugin.saveSettings();
|
await this.plugin.saveSettings();
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|||||||
+3
-3
@@ -14,14 +14,14 @@
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
app: ObsidianApp;
|
app: ObsidianApp;
|
||||||
|
chatsFolder: () => string;
|
||||||
client: ApiClient | null;
|
client: ApiClient | null;
|
||||||
component: Component;
|
component: Component;
|
||||||
onOpenSettings: () => void;
|
onOpenSettings: () => void;
|
||||||
state: PanelState;
|
state: PanelState;
|
||||||
vaultSubpath: () => string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let { app, client, component, onOpenSettings, state, vaultSubpath }: Props =
|
let { app, client, component, onOpenSettings, state, chatsFolder }: Props =
|
||||||
$props();
|
$props();
|
||||||
|
|
||||||
// Menus hang off body, not off the view: Obsidian's sidedocks paint above
|
// Menus hang off body, not off the view: Obsidian's sidedocks paint above
|
||||||
@@ -41,9 +41,9 @@
|
|||||||
const host = providePanelHost({
|
const host = providePanelHost({
|
||||||
...obsidianHost({
|
...obsidianHost({
|
||||||
app,
|
app,
|
||||||
|
chatsFolder,
|
||||||
component,
|
component,
|
||||||
sourcePath: () => state.sourcePath,
|
sourcePath: () => state.sourcePath,
|
||||||
vaultSubpath,
|
|
||||||
}),
|
}),
|
||||||
cache,
|
cache,
|
||||||
});
|
});
|
||||||
|
|||||||
+20
-7
@@ -1,14 +1,27 @@
|
|||||||
import type { App, Component } from "obsidian";
|
import type { App, Component } from "obsidian";
|
||||||
import { MarkdownRenderer, Platform } from "obsidian";
|
import { MarkdownPreviewRenderer, MarkdownRenderer, Platform } from "obsidian";
|
||||||
|
import { inertFences } from "$lib/panel/fences";
|
||||||
import type { PanelHost } from "$lib/panel/host";
|
import type { PanelHost } from "$lib/panel/host";
|
||||||
|
|
||||||
export interface HostOptions {
|
export interface HostOptions {
|
||||||
app: App;
|
app: App;
|
||||||
|
// Folder in this vault that maps to the gateway's vault root.
|
||||||
|
// Where the deep chats live in this vault; gateway paths are relative to it.
|
||||||
|
chatsFolder: () => string;
|
||||||
component: Component;
|
component: Component;
|
||||||
// The note the panel currently follows; link resolution starts from it.
|
// The note the panel currently follows; link resolution starts from it.
|
||||||
sourcePath: () => string;
|
sourcePath: () => string;
|
||||||
// Folder in this vault that maps to the gateway's vault root.
|
}
|
||||||
vaultSubpath: () => string;
|
|
||||||
|
const QUERY_LANGUAGES = new Set(["tasks", "dataview", "dataviewjs", "query"]);
|
||||||
|
|
||||||
|
function runs(language: string): boolean {
|
||||||
|
const registered = (
|
||||||
|
MarkdownPreviewRenderer as unknown as {
|
||||||
|
codeBlockPostProcessors?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
).codeBlockPostProcessors;
|
||||||
|
return QUERY_LANGUAGES.has(language) || language in (registered ?? {});
|
||||||
}
|
}
|
||||||
|
|
||||||
// The Obsidian side of the panel: markdown through the app's own renderer,
|
// The Obsidian side of the panel: markdown through the app's own renderer,
|
||||||
@@ -16,12 +29,12 @@ export interface HostOptions {
|
|||||||
export function obsidianHost(options: HostOptions): PanelHost {
|
export function obsidianHost(options: HostOptions): PanelHost {
|
||||||
const { app, component } = options;
|
const { app, component } = options;
|
||||||
return {
|
return {
|
||||||
markdown(node, text) {
|
markdown(node, text, { inert = false } = {}) {
|
||||||
let alive = true;
|
let alive = true;
|
||||||
const staging = document.createElement("div");
|
const staging = document.createElement("div");
|
||||||
MarkdownRenderer.render(
|
MarkdownRenderer.render(
|
||||||
app,
|
app,
|
||||||
text,
|
inert ? inertFences(text, runs) : text,
|
||||||
staging,
|
staging,
|
||||||
options.sourcePath(),
|
options.sourcePath(),
|
||||||
component
|
component
|
||||||
@@ -47,8 +60,8 @@ export function obsidianHost(options: HostOptions): PanelHost {
|
|||||||
window.open(target);
|
window.open(target);
|
||||||
},
|
},
|
||||||
openNote(path) {
|
openNote(path) {
|
||||||
const subpath = options.vaultSubpath();
|
const folder = options.chatsFolder();
|
||||||
const full = subpath ? `${subpath}/${path}` : path;
|
const full = folder ? `${folder}/${path}` : path;
|
||||||
app.workspace.openLinkText(full, "", false).catch(() => undefined);
|
app.workspace.openLinkText(full, "", false).catch(() => undefined);
|
||||||
},
|
},
|
||||||
touch: Platform.isMobile,
|
touch: Platform.isMobile,
|
||||||
|
|||||||
+82
-36
@@ -1,6 +1,8 @@
|
|||||||
import type { ViewStateResult, WorkspaceLeaf } from "obsidian";
|
import type { ViewStateResult, WorkspaceLeaf } from "obsidian";
|
||||||
import { ItemView, TFile } from "obsidian";
|
import { ItemView, TFile } from "obsidian";
|
||||||
import { mount, unmount } from "svelte";
|
import { mount, unmount } from "svelte";
|
||||||
|
import type { Rect } from "./inset";
|
||||||
|
import { insetFor } from "./inset";
|
||||||
import type BeaverPlugin from "./main";
|
import type BeaverPlugin from "./main";
|
||||||
import { PanelState } from "./state.svelte";
|
import { PanelState } from "./state.svelte";
|
||||||
import App from "./ui/app.svelte";
|
import App from "./ui/app.svelte";
|
||||||
@@ -11,8 +13,18 @@ export const VIEW_TYPE_PANEL = "beaver-panel";
|
|||||||
// sits under every view, including the one filling the screen.
|
// sits under every view, including the one filling the screen.
|
||||||
const BOTTOM_BARS = [".status-bar", ".mobile-navbar", ".mobile-toolbar"];
|
const BOTTOM_BARS = [".status-bar", ".mobile-navbar", ".mobile-toolbar"];
|
||||||
|
|
||||||
// iOS animates the keyboard in; the viewport is only honest once it is done.
|
// The keyboard and the navbar both animate, and Obsidian hides the navbar
|
||||||
const KEYBOARD_SETTLE_MS = 350;
|
// *after* the keyboard is up: the inset is re-measured every frame until the
|
||||||
|
// reading has held still long enough to outlast both moves.
|
||||||
|
const STEADY_FRAMES = 12;
|
||||||
|
|
||||||
|
/** Obsidian's Capacitor keyboard events on mobile; absent on the desktop. */
|
||||||
|
const KEYBOARD_EVENTS = [
|
||||||
|
"keyboardWillShow",
|
||||||
|
"keyboardDidShow",
|
||||||
|
"keyboardWillHide",
|
||||||
|
"keyboardDidHide",
|
||||||
|
] as const;
|
||||||
|
|
||||||
interface SavedState {
|
interface SavedState {
|
||||||
follow?: unknown;
|
follow?: unknown;
|
||||||
@@ -26,6 +38,7 @@ export class PanelView extends ItemView {
|
|||||||
readonly state = new PanelState();
|
readonly state = new PanelState();
|
||||||
private readonly plugin: BeaverPlugin;
|
private readonly plugin: BeaverPlugin;
|
||||||
private root: Record<string, unknown> | null = null;
|
private root: Record<string, unknown> | null = null;
|
||||||
|
private settling: number | null = null;
|
||||||
|
|
||||||
constructor(leaf: WorkspaceLeaf, plugin: BeaverPlugin) {
|
constructor(leaf: WorkspaceLeaf, plugin: BeaverPlugin) {
|
||||||
super(leaf);
|
super(leaf);
|
||||||
@@ -82,15 +95,15 @@ export class PanelView extends ItemView {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
this.registerEvent(this.app.workspace.on("resize", () => this.inset()));
|
this.registerEvent(this.app.workspace.on("resize", () => this.settle()));
|
||||||
this.registerEvent(
|
this.registerEvent(
|
||||||
this.app.workspace.on("layout-change", () => this.inset())
|
this.app.workspace.on("layout-change", () => this.settle())
|
||||||
);
|
);
|
||||||
// The keyboard resizes the visual viewport only: no workspace event
|
// The keyboard resizes the visual viewport only: no workspace event
|
||||||
// fires, so the composer would stay behind it without these two.
|
// fires, so the composer would stay behind it without these two.
|
||||||
const vv = window.visualViewport;
|
const vv = window.visualViewport;
|
||||||
if (vv) {
|
if (vv) {
|
||||||
const onViewport = () => this.inset();
|
const onViewport = () => this.settle();
|
||||||
vv.addEventListener("resize", onViewport);
|
vv.addEventListener("resize", onViewport);
|
||||||
vv.addEventListener("scroll", onViewport);
|
vv.addEventListener("scroll", onViewport);
|
||||||
this.register(() => {
|
this.register(() => {
|
||||||
@@ -98,49 +111,82 @@ export class PanelView extends ItemView {
|
|||||||
vv.removeEventListener("scroll", onViewport);
|
vv.removeEventListener("scroll", onViewport);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// Focusing the composer opens the keyboard a beat before the viewport
|
// Focus opens the keyboard, blur closes it; both animate, and on the way
|
||||||
// settles; re-measure once it has.
|
// out the navbar comes back, so the leaf has to be re-measured after.
|
||||||
this.registerDomEvent(this.contentEl, "focusin", () => {
|
this.registerDomEvent(this.contentEl, "focusin", () => this.settle());
|
||||||
window.setTimeout(() => this.inset(), KEYBOARD_SETTLE_MS);
|
this.registerDomEvent(this.contentEl, "focusout", () => this.settle());
|
||||||
});
|
// Obsidian's own keyboard events (Capacitor, mobile only) fire before the
|
||||||
|
// animation and again as the navbar returns, which visualViewport alone
|
||||||
|
// does not always report.
|
||||||
|
for (const name of KEYBOARD_EVENTS) {
|
||||||
|
const onKeyboard = () => this.settle();
|
||||||
|
window.addEventListener(name, onKeyboard);
|
||||||
|
this.register(() => window.removeEventListener(name, onKeyboard));
|
||||||
|
}
|
||||||
this.follow(this.app.workspace.getActiveFile());
|
this.follow(this.app.workspace.getActiveFile());
|
||||||
this.plugin.views.add(this);
|
this.plugin.views.add(this);
|
||||||
this.mountApp();
|
this.mountApp();
|
||||||
requestAnimationFrame(() => this.inset());
|
this.settle();
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Whatever floats over the bottom of this leaf, measured rather than
|
/**
|
||||||
// guessed: the desktop status bar, the phone's navbar (which the workspace
|
* Re-measure until the viewport stops moving. The keyboard animates both
|
||||||
// does not subtract from a leaf's height), and the software keyboard, which
|
* ways and Obsidian hides its navbar while it is up, so a fixed delay lands
|
||||||
// shrinks the visual viewport without resizing the layout at all.
|
* mid-animation; a frame loop that stops once two readings agree does not
|
||||||
private inset(): void {
|
* care how long the animation takes, or whether any event fires at all -
|
||||||
const box = this.contentEl.getBoundingClientRect();
|
* which is the case in a sidedock drawer.
|
||||||
if (box.height <= 0) {
|
*/
|
||||||
|
private settle(): void {
|
||||||
|
let last = Number.NaN;
|
||||||
|
let steady = 0;
|
||||||
|
// A fresh trigger restarts the watch rather than being dropped: the navbar
|
||||||
|
// coming back is a second move, after the first one has settled.
|
||||||
|
if (this.settling !== null) {
|
||||||
|
cancelAnimationFrame(this.settling);
|
||||||
|
}
|
||||||
|
const tick = () => {
|
||||||
|
const floor = this.inset();
|
||||||
|
steady = floor === last ? steady + 1 : 0;
|
||||||
|
last = floor;
|
||||||
|
if (steady >= STEADY_FRAMES) {
|
||||||
|
this.settling = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let overlap = 0;
|
this.settling = requestAnimationFrame(tick);
|
||||||
for (const bar of BOTTOM_BARS) {
|
};
|
||||||
const node = document.querySelector<HTMLElement>(bar);
|
this.settling = requestAnimationFrame(tick);
|
||||||
if (!node || node.offsetParent === null) {
|
this.register(() => {
|
||||||
continue;
|
if (this.settling !== null) {
|
||||||
|
cancelAnimationFrame(this.settling);
|
||||||
|
this.settling = null;
|
||||||
}
|
}
|
||||||
const b = node.getBoundingClientRect();
|
});
|
||||||
if (b.height > 0 && b.left < box.right && b.right > box.left) {
|
}
|
||||||
overlap = Math.max(overlap, box.bottom - b.top);
|
|
||||||
|
// Whatever floats over the bottom of this leaf, measured rather than
|
||||||
|
// guessed. Returns the inset it wrote, so the settle loop can tell when the
|
||||||
|
// reading stops changing; the arithmetic itself lives in ``inset.ts``.
|
||||||
|
private inset(): number {
|
||||||
|
const box = this.contentEl.getBoundingClientRect();
|
||||||
|
if (box.height <= 0) {
|
||||||
|
return Number.NaN;
|
||||||
|
}
|
||||||
|
const bars: Rect[] = [];
|
||||||
|
for (const selector of BOTTOM_BARS) {
|
||||||
|
const node = document.querySelector<HTMLElement>(selector);
|
||||||
|
if (node && node.offsetParent !== null) {
|
||||||
|
bars.push(node.getBoundingClientRect());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const vv = window.visualViewport;
|
const vv = window.visualViewport;
|
||||||
if (vv) {
|
const inset = insetFor(
|
||||||
// The keyboard eats the bottom of the window; anything of this leaf
|
box,
|
||||||
// below that line is unreachable until it goes away.
|
bars,
|
||||||
const floor = vv.offsetTop + vv.height;
|
vv ? { height: vv.height, offsetTop: vv.offsetTop } : null
|
||||||
overlap = Math.max(overlap, box.bottom - floor);
|
|
||||||
}
|
|
||||||
this.contentEl.style.setProperty(
|
|
||||||
"--beaver-inset-bottom",
|
|
||||||
`${Math.max(0, Math.round(overlap))}px`
|
|
||||||
);
|
);
|
||||||
|
this.contentEl.style.setProperty("--beaver-inset-bottom", `${inset}px`);
|
||||||
|
return inset;
|
||||||
}
|
}
|
||||||
|
|
||||||
async onClose(): Promise<void> {
|
async onClose(): Promise<void> {
|
||||||
@@ -185,11 +231,11 @@ export class PanelView extends ItemView {
|
|||||||
this.root = mount(App, {
|
this.root = mount(App, {
|
||||||
props: {
|
props: {
|
||||||
app: this.app,
|
app: this.app,
|
||||||
|
chatsFolder: () => this.plugin.settings.chatsFolder,
|
||||||
client: this.plugin.apiClient(),
|
client: this.plugin.apiClient(),
|
||||||
component: this,
|
component: this,
|
||||||
onOpenSettings: () => this.plugin.openSettings(),
|
onOpenSettings: () => this.plugin.openSettings(),
|
||||||
state: this.state,
|
state: this.state,
|
||||||
vaultSubpath: () => this.plugin.settings.vaultSubpath,
|
|
||||||
},
|
},
|
||||||
target: this.contentEl,
|
target: this.contentEl,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
// Evaluate an expression inside a running Obsidian started with
|
||||||
|
// --remote-debugging-port. Usage: bun t/cdp-eval.ts <port> "<js expression>"
|
||||||
|
const [port, expression] = process.argv.slice(2);
|
||||||
|
const targets = (await (
|
||||||
|
await fetch(`http://localhost:${port}/json`)
|
||||||
|
).json()) as {
|
||||||
|
type: string;
|
||||||
|
title: string;
|
||||||
|
webSocketDebuggerUrl: string;
|
||||||
|
}[];
|
||||||
|
const page = targets.find((t) => t.type === "page");
|
||||||
|
if (!page) {
|
||||||
|
throw new Error("no page target");
|
||||||
|
}
|
||||||
|
const ws = new WebSocket(page.webSocketDebuggerUrl);
|
||||||
|
await new Promise((resolve) => ws.addEventListener("open", resolve));
|
||||||
|
const reply = new Promise<string>((resolve) => {
|
||||||
|
ws.addEventListener("message", (event) => {
|
||||||
|
const data = JSON.parse(String(event.data));
|
||||||
|
if (data.id === 1) {
|
||||||
|
resolve(
|
||||||
|
JSON.stringify(data.result?.result?.value ?? data.result ?? data)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
id: 1,
|
||||||
|
method: "Runtime.evaluate",
|
||||||
|
params: { awaitPromise: true, expression, returnByValue: true },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
console.log(await reply);
|
||||||
|
ws.close();
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { inertFences } from "$lib/panel/fences";
|
||||||
|
|
||||||
|
const runs = (language: string) => ["tasks", "dataview"].includes(language);
|
||||||
|
|
||||||
|
describe("inertFences", () => {
|
||||||
|
it("turns a query block into code", () => {
|
||||||
|
expect(inertFences("```tasks\nnot done\n```", runs)).toBe(
|
||||||
|
"```text\nnot done\n```"
|
||||||
|
);
|
||||||
|
expect(inertFences("~~~Dataview\nlist\n~~~", runs)).toBe(
|
||||||
|
"~~~text\nlist\n~~~"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reaches a block opened inside a list item or a quote", () => {
|
||||||
|
expect(inertFences("+ ```tasks\n+ not done", runs)).toBe(
|
||||||
|
"+ ```text\n+ not done"
|
||||||
|
);
|
||||||
|
expect(inertFences("> 1. ````tasks {x}", runs)).toBe("> 1. ````text {x}");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves ordinary code and closing fences alone", () => {
|
||||||
|
const text = "```python\nprint(1)\n```\ninline `tasks` stays";
|
||||||
|
expect(inertFences(text, runs)).toBe(text);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { Rect } from "../src/inset";
|
||||||
|
import { insetFor } from "../src/inset";
|
||||||
|
|
||||||
|
const rect = (over: Partial<Rect> = {}): Rect => ({
|
||||||
|
bottom: 780,
|
||||||
|
height: 780,
|
||||||
|
left: 0,
|
||||||
|
right: 390,
|
||||||
|
top: 0,
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
// A phone: 780 tall, a 56px navbar over the bottom, 340px of keyboard.
|
||||||
|
const NAVBAR = rect({ bottom: 780, height: 56, top: 724 });
|
||||||
|
|
||||||
|
describe("insetFor", () => {
|
||||||
|
it("clears nothing when the leaf stands alone", () => {
|
||||||
|
expect(insetFor(rect(), [], null)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears the navbar that paints over the leaf", () => {
|
||||||
|
expect(insetFor(rect(), [NAVBAR], null)).toBe(56);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears the keyboard, which shortens no layout", () => {
|
||||||
|
expect(insetFor(rect(), [], { height: 440, offsetTop: 0 })).toBe(340);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("takes the deeper of the two rather than their sum", () => {
|
||||||
|
// While the keyboard is up the navbar is behind it: adding them would
|
||||||
|
// push the composer twice as far as it has to go.
|
||||||
|
expect(insetFor(rect(), [NAVBAR], { height: 440, offsetTop: 0 })).toBe(340);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("comes back to the navbar once the keyboard goes", () => {
|
||||||
|
expect(insetFor(rect(), [NAVBAR], { height: 780, offsetTop: 0 })).toBe(56);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores a bar that sits beside the leaf, not over it", () => {
|
||||||
|
// The desktop status bar floats bottom-right; a left-hand sidedock never
|
||||||
|
// reaches it.
|
||||||
|
const sidedock = rect({ right: 300 });
|
||||||
|
const statusBar = rect({
|
||||||
|
bottom: 780,
|
||||||
|
height: 20,
|
||||||
|
left: 700,
|
||||||
|
right: 980,
|
||||||
|
top: 760,
|
||||||
|
});
|
||||||
|
expect(insetFor(sidedock, [statusBar], null)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("measures from where the visible area starts, not from zero", () => {
|
||||||
|
// iOS scrolls the page up under the keyboard instead of resizing it.
|
||||||
|
expect(insetFor(rect(), [], { height: 440, offsetTop: 100 })).toBe(240);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says nothing about a leaf with no height yet", () => {
|
||||||
|
expect(insetFor(rect({ bottom: 0, height: 0 }), [NAVBAR], null)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never returns a negative inset", () => {
|
||||||
|
const short = rect({ bottom: 300, height: 300 });
|
||||||
|
expect(insetFor(short, [], { height: 780, offsetTop: 0 })).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -34,6 +34,12 @@ export const moment = () => undefined;
|
|||||||
export const requestUrl = () =>
|
export const requestUrl = () =>
|
||||||
Promise.reject(new Error("no network in the stub"));
|
Promise.reject(new Error("no network in the stub"));
|
||||||
export const setIcon = () => undefined;
|
export const setIcon = () => undefined;
|
||||||
|
export const MarkdownPreviewRenderer = {
|
||||||
|
codeBlockPostProcessors: { tasks: () => undefined } as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>,
|
||||||
|
};
|
||||||
|
|
||||||
function escapeHtml(value: string): string {
|
function escapeHtml(value: string): string {
|
||||||
return value
|
return value
|
||||||
|
|||||||
+1
-1
@@ -137,11 +137,11 @@ const target = dom.window.document.body;
|
|||||||
const instance = mount(App, {
|
const instance = mount(App, {
|
||||||
props: {
|
props: {
|
||||||
app,
|
app,
|
||||||
|
chatsFolder: () => "💬 чаты",
|
||||||
client,
|
client,
|
||||||
component: new Component(),
|
component: new Component(),
|
||||||
onOpenSettings: () => {},
|
onOpenSettings: () => {},
|
||||||
state,
|
state,
|
||||||
vaultSubpath: () => "💬 чаты",
|
|
||||||
},
|
},
|
||||||
target,
|
target,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { defineConfig } from "vitest/config";
|
|||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
|
$lib: new URL("../beaver-gateway/ui/src/lib", import.meta.url).pathname,
|
||||||
obsidian: new URL("./tests/obsidian-stub.ts", import.meta.url).pathname,
|
obsidian: new URL("./tests/obsidian-stub.ts", import.meta.url).pathname,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in new issue
Block a user