Update browser modal to side-panel view

The browser interface has been refactored from a modal to a persistent side-panel view that can be opened and closed
like other Obsidian panes. This provides better workflow integration and allows users to keep the browser open while
working with other notes.

The documentation has been updated to clarify that the "Browse reMarkable" command now opens the side-panel browser
instead of a modal dialog.
This commit is contained in:
2026-05-31 18:43:35 +02:00
parent 23ee2314b3
commit 7a6cc78e17
8 changed files with 192 additions and 85 deletions
+1 -1
View File
@@ -97,7 +97,7 @@ obidian-remarkable/
### Daily Use ### Daily Use
- **Sync all**: Click ribbon pencil icon or run command "Sync from reMarkable" - **Sync all**: Click ribbon pencil icon or run command "Sync from reMarkable"
- **Browse files**: Click ribbon folder icon or run command "Browse reMarkable" - **Browse files**: Click ribbon folder icon or run command "Browse reMarkable" to open the side-panel browser
- **Per-file actions**: In the browser, click "Import" or "Handwriting to Markdown" for each document - **Per-file actions**: In the browser, click "Import" or "Handwriting to Markdown" for each document
- **Convert single file**: Open a `.rm` file in Obsidian, run "Convert handwriting to Markdown" - **Convert single file**: Open a `.rm` file in Obsidian, run "Convert handwriting to Markdown"
+7 -7
View File
File diff suppressed because one or more lines are too long
+7 -7
View File
File diff suppressed because one or more lines are too long
+24 -3
View File
@@ -5,7 +5,7 @@ import { RmapiBridge } from "./rmapi/bridge";
import { OcrPipeline } from "./ocr/pipeline"; import { OcrPipeline } from "./ocr/pipeline";
import { SyncTracker } from "./sync/tracker"; import { SyncTracker } from "./sync/tracker";
import { RemarkableSettings } from "./types"; import { RemarkableSettings } from "./types";
import { RemarkableBrowserModal } from "./ui/browser-modal"; import { REMARKABLE_BROWSER_VIEW_TYPE, RemarkableBrowserView } from "./ui/browser-view";
export default class RemarkablePlugin extends Plugin { export default class RemarkablePlugin extends Plugin {
settings: RemarkableSettings; settings: RemarkableSettings;
@@ -24,6 +24,11 @@ export default class RemarkablePlugin extends Plugin {
this.downloader = new DocumentDownloader(this); this.downloader = new DocumentDownloader(this);
this.ocrPipeline = new OcrPipeline(this); this.ocrPipeline = new OcrPipeline(this);
this.registerView(
REMARKABLE_BROWSER_VIEW_TYPE,
(leaf) => new RemarkableBrowserView(leaf, this),
);
// Validate OCR dependencies // Validate OCR dependencies
if (this.settings.enableHandwritingMd) { if (this.settings.enableHandwritingMd) {
const missing = await this.ocrPipeline.validateDependencies(); const missing = await this.ocrPipeline.validateDependencies();
@@ -130,8 +135,24 @@ export default class RemarkablePlugin extends Plugin {
} }
} }
openRemarkableBrowser(): void { async openRemarkableBrowser(): Promise<void> {
new RemarkableBrowserModal(this).open(); const existingLeaf = this.app.workspace.getLeavesOfType(REMARKABLE_BROWSER_VIEW_TYPE)[0];
if (existingLeaf) {
await this.app.workspace.revealLeaf(existingLeaf);
return;
}
const leaf = this.app.workspace.getRightLeaf(false) || this.app.workspace.getRightLeaf(true);
if (!leaf) {
new Notice("Could not open reMarkable browser pane.");
return;
}
await leaf.setViewState({
type: REMARKABLE_BROWSER_VIEW_TYPE,
active: true,
});
await this.app.workspace.revealLeaf(leaf);
} }
updateStatusBar(text: string): void { updateStatusBar(text: string): void {
@@ -1,25 +1,39 @@
import { ButtonComponent, Modal, Notice } from "obsidian"; import { ButtonComponent, ItemView, Notice, WorkspaceLeaf } from "obsidian";
import RemarkablePlugin from "../main"; import RemarkablePlugin from "../main";
import { RmapiNode } from "../types"; import { RmapiNode } from "../types";
export class RemarkableBrowserModal extends Modal { export const REMARKABLE_BROWSER_VIEW_TYPE = "remarkable-browser-view";
export class RemarkableBrowserView extends ItemView {
private plugin: RemarkablePlugin; private plugin: RemarkablePlugin;
private currentPath = "/"; private currentPath = "/";
constructor(plugin: RemarkablePlugin) { constructor(leaf: WorkspaceLeaf, plugin: RemarkablePlugin) {
super(plugin.app); super(leaf);
this.plugin = plugin; this.plugin = plugin;
} }
onOpen(): void { getViewType(): string {
this.render(); return REMARKABLE_BROWSER_VIEW_TYPE;
}
getDisplayText(): string {
return "reMarkable";
}
getIcon(): string {
return "folder-open";
}
async onOpen(): Promise<void> {
await this.render();
} }
onClose(): void { onClose(): void {
this.contentEl.empty(); this.contentEl.empty();
} }
private async render(): Promise<void> { async render(): Promise<void> {
const { contentEl } = this; const { contentEl } = this;
contentEl.empty(); contentEl.empty();
contentEl.createEl("h2", { text: "Browse reMarkable" }); contentEl.createEl("h2", { text: "Browse reMarkable" });
-60
View File
@@ -1,60 +0,0 @@
import test from "node:test";
import assert from "node:assert/strict";
import { ButtonComponent } from "obsidian";
import { RemarkableBrowserModal } from "../src/ui/browser-modal";
import { RmapiNode } from "../src/types";
function createDocument(name: string): RmapiNode {
return {
id: `id-${name}`,
name,
type: "DocumentType",
version: 1,
modifiedClient: "2026-05-31T10:00:00Z",
currentPage: 0,
parent: "",
tags: [],
starred: false,
};
}
function createPlugin(nodes: RmapiNode[]) {
const calls: any[] = [];
const plugin: any = {
app: {},
rmapi: {
list: async () => nodes,
},
downloader: {
downloadDocument: async (...args: any[]) => {
calls.push(args);
},
},
};
return { calls, plugin };
}
test("RemarkableBrowserModal creates import and handwriting buttons for documents", async () => {
ButtonComponent.instances = [];
const doc = createDocument("Meeting Notes");
const { calls, plugin } = createPlugin([doc]);
const modal = new RemarkableBrowserModal(plugin);
modal.onOpen();
await new Promise((resolve) => setImmediate(resolve));
const importButton = ButtonComponent.instances.find((button) => button.buttonEl.text === "Import");
const markdownButton = ButtonComponent.instances.find((button) => button.buttonEl.text === "Handwriting to Markdown");
assert.ok(importButton);
assert.ok(markdownButton);
await importButton.click();
await markdownButton.click();
assert.deepEqual(calls, [
[{ node: doc, remotePath: "/Meeting Notes" }, { convertToMd: false, forceDownload: true }],
[{ node: doc, remotePath: "/Meeting Notes" }, { convertToMd: true, forceDownload: true }],
]);
});
+107
View File
@@ -0,0 +1,107 @@
import test from "node:test";
import assert from "node:assert/strict";
import { ButtonComponent } from "obsidian";
import RemarkablePlugin from "../src/main";
import { REMARKABLE_BROWSER_VIEW_TYPE, RemarkableBrowserView } from "../src/ui/browser-view";
import { RmapiNode } from "../src/types";
function createDocument(name: string): RmapiNode {
return {
id: `id-${name}`,
name,
type: "DocumentType",
version: 1,
modifiedClient: "2026-05-31T10:00:00Z",
currentPage: 0,
parent: "",
tags: [],
starred: false,
};
}
function createPlugin(nodes: RmapiNode[]) {
const calls: any[] = [];
const plugin: any = {
app: {},
rmapi: {
list: async () => nodes,
},
downloader: {
downloadDocument: async (...args: any[]) => {
calls.push(args);
},
},
};
return { calls, plugin };
}
test("RemarkableBrowserView creates import and handwriting buttons for documents", async () => {
ButtonComponent.instances = [];
const doc = createDocument("Meeting Notes");
const { calls, plugin } = createPlugin([doc]);
const view = new RemarkableBrowserView({} as any, plugin);
await view.onOpen();
const importButton = ButtonComponent.instances.find((button) => button.buttonEl.text === "Import");
const markdownButton = ButtonComponent.instances.find((button) => button.buttonEl.text === "Handwriting to Markdown");
assert.ok(importButton);
assert.ok(markdownButton);
await importButton.click();
await markdownButton.click();
assert.deepEqual(calls, [
[{ node: doc, remotePath: "/Meeting Notes" }, { convertToMd: false, forceDownload: true }],
[{ node: doc, remotePath: "/Meeting Notes" }, { convertToMd: true, forceDownload: true }],
]);
});
test("openRemarkableBrowser opens the browser view in the right sidebar", async () => {
const calls: any[] = [];
const leaf = {
setViewState: async (state: any) => {
calls.push(["setViewState", state]);
},
};
const plugin: any = new RemarkablePlugin();
plugin.app = {
workspace: {
getLeavesOfType: () => [],
getRightLeaf: () => leaf,
revealLeaf: async (targetLeaf: any) => {
calls.push(["revealLeaf", targetLeaf]);
},
},
};
await plugin.openRemarkableBrowser();
assert.deepEqual(calls, [
["setViewState", { type: REMARKABLE_BROWSER_VIEW_TYPE, active: true }],
["revealLeaf", leaf],
]);
});
test("openRemarkableBrowser reveals an existing browser leaf", async () => {
const calls: any[] = [];
const existingLeaf = {};
const plugin: any = new RemarkablePlugin();
plugin.app = {
workspace: {
getLeavesOfType: () => [existingLeaf],
getRightLeaf: () => {
throw new Error("should not create a new leaf");
},
revealLeaf: async (targetLeaf: any) => {
calls.push(targetLeaf);
},
},
};
await plugin.openRemarkableBrowser();
assert.deepEqual(calls, [existingLeaf]);
});
+25
View File
@@ -22,6 +22,7 @@ export class Plugin {
addRibbonIcon(): void {} addRibbonIcon(): void {}
addCommand(): void {} addCommand(): void {}
addSettingTab(): void {} addSettingTab(): void {}
registerView(): void {}
addStatusBarItem(): { setText: (text: string) => void } { addStatusBarItem(): { setText: (text: string) => void } {
return { setText: () => {} }; return { setText: () => {} };
} }
@@ -62,6 +63,30 @@ export class Modal {
onClose(): void {} onClose(): void {}
} }
export class ItemView {
leaf: any;
contentEl = createElement();
constructor(leaf: any) {
this.leaf = leaf;
}
getViewType(): string {
return "";
}
getDisplayText(): string {
return "";
}
getIcon(): string {
return "";
}
async onOpen(): Promise<void> {}
onClose(): void {}
}
export class ButtonComponent { export class ButtonComponent {
static instances: ButtonComponent[] = []; static instances: ButtonComponent[] = [];
buttonEl = createElement(); buttonEl = createElement();