feat: openItemRef helper for opening registry item refs, update locator

modal to use openItemRef

This brings the behavior of scrolling to the line the timesheet is on
like the status bar items have
This commit is contained in:
Jacobtread 2026-04-10 00:27:36 +12:00
parent fac1467a5d
commit 3da8d5415c
6 changed files with 126 additions and 29 deletions

View file

@ -495,6 +495,13 @@ export const MockNotice = vi.fn(
}
);
export class MockMarkdownView {
editor = {
setCursor: vi.fn(),
scrollIntoView: vi.fn(),
};
}
/**
* Helper to create the {@link Node.createEl} function added by obsidian
*

View file

@ -3,6 +3,7 @@ import { afterEach, vi } from "vitest";
import {
MockButtonComponent,
MockComponent,
MockMarkdownView,
MockModal,
MockNotice,
MockSetting,
@ -34,5 +35,6 @@ vi.mock("obsidian", () => {
}),
Setting: MockSetting,
ButtonComponent: MockButtonComponent,
MarkdownView: MockMarkdownView,
};
});

View file

@ -1,4 +1,5 @@
import { App, Component, MarkdownView } from "obsidian";
import { type App } from "obsidian";
import { Component } from "obsidian";
import {
TimekeepEntryItemType,
@ -117,18 +118,6 @@ export class TimesheetStatusBar extends Component {
}
async onOpen(ref: TimekeepRegistryItemRef) {
const leaf = this.app.workspace.getLeaf();
await leaf.openFile(ref.file);
const view = leaf.view;
if (view instanceof MarkdownView && ref.type === TimekeepEntryItemType.MARKDOWN) {
const editor = view.editor;
const line = ref.position.startLine;
// Focus the line we opened to
editor.setCursor({ line: Math.max(line - 1, 0), ch: 0 });
editor.scrollIntoView({ from: { line, ch: 0 }, to: { line, ch: 0 } }, true);
}
await TimekeepRegistry.openItemRef(this.app.workspace, ref);
}
}

View file

@ -1,18 +1,22 @@
import type { App, TFile } from "obsidian";
import type { App } from "obsidian";
import { SuggestModal } from "obsidian";
import type { TimekeepSettings } from "@/settings";
import type { Store } from "@/store";
import type { Timekeep, TimeEntry } from "@/timekeep/schema";
import type { TimeEntry } from "@/timekeep/schema";
import { TimekeepEntryItemType, TimekeepRegistry, TimekeepRegistryEntry } from "@/service/registry";
import {
TimekeepEntryItemType,
TimekeepRegistry,
TimekeepRegistryEntry,
TimekeepRegistryItemRef,
} from "@/service/registry";
import { getRunningEntry } from "@/timekeep/queries";
interface TimekeepResult {
timekeep: Timekeep;
ref: TimekeepRegistryItemRef;
running: TimeEntry;
file: TFile;
}
export class TimekeepLocatorModal extends SuggestModal<TimekeepResult> {
@ -49,18 +53,18 @@ export class TimekeepLocatorModal extends SuggestModal<TimekeepResult> {
return this.results.filter((result) => {
return (
result.running.name.toLowerCase().contains(queryLower) ||
result.file.path.toLowerCase().contains(queryLower)
result.ref.file.path.toLowerCase().contains(queryLower)
);
});
}
renderSuggestion(value: TimekeepResult, el: HTMLElement) {
el.createEl("div", { text: value.running.name });
el.createEl("small", { text: value.file.path });
el.createEl("small", { text: value.ref.file.path });
}
onChooseSuggestion(item: TimekeepResult, _evt: MouseEvent | KeyboardEvent) {
void this.app.workspace.getLeaf().openFile(item.file);
async onChooseSuggestion(item: TimekeepResult, _evt: MouseEvent | KeyboardEvent) {
await TimekeepRegistry.openItemRef(this.app.workspace, item.ref);
}
static getResultsFromEntries(entries: TimekeepRegistryEntry[]): TimekeepResult[] {
@ -71,7 +75,13 @@ export class TimekeepLocatorModal extends SuggestModal<TimekeepResult> {
const timekeep = entry.timekeep;
const running = getRunningEntry(timekeep.entries);
if (running !== null) {
results.push({ file: entry.file, running: running, timekeep });
results.push({
running: running,
ref: {
type: entry.type,
file: entry.file,
},
});
}
break;
}
@ -80,7 +90,14 @@ export class TimekeepLocatorModal extends SuggestModal<TimekeepResult> {
const timekeep = timekeepWithPosition.timekeep;
const running = getRunningEntry(timekeep.entries);
if (running !== null) {
results.push({ file: entry.file, running: running, timekeep });
results.push({
running: running,
ref: {
type: entry.type,
file: entry.file,
position: timekeepWithPosition,
},
});
}
}

View file

@ -1,14 +1,21 @@
import type { Workspace } from "obsidian";
import moment from "moment";
import { v4 } from "uuid";
import { describe, vi, it, expect } from "vitest";
import { MockVault } from "@/__mocks__/obsidian";
import { MockMarkdownView, MockVault } from "@/__mocks__/obsidian";
import { defaultSettings } from "@/settings";
import { createStore } from "@/store";
import { stripTimekeepRuntimeData, Timekeep } from "@/timekeep/schema";
import { createCodeBlock } from "@/utils/codeblock";
import { TimekeepEntryItemType, TimekeepRegistry, TimekeepRegistryEntryMarkdown } from "./registry";
import {
TimekeepEntryItemType,
TimekeepRegistry,
TimekeepRegistryEntryMarkdown,
TimekeepRegistryItemRef,
} from "./registry";
describe("TimekeepRegistry", () => {
describe("getFileRegistryEntry", () => {
@ -641,4 +648,63 @@ describe("TimekeepRegistry", () => {
expect(spy).toHaveBeenCalled();
});
});
describe("openItemRef", () => {
it("should attempt to open the file", async () => {
const vault = new MockVault();
const testFile = vault.addFile("test.md", "");
const ref: TimekeepRegistryItemRef = {
file: testFile,
type: TimekeepEntryItemType.FILE,
};
const openFile = vi.fn().mockResolvedValue(undefined);
const workspace = {
getLeaf() {
const view = new MockMarkdownView();
(view as any).openFile = openFile;
return view;
},
} as any as Workspace;
await TimekeepRegistry.openItemRef(workspace, ref);
expect(openFile).toHaveBeenCalled();
});
it("opening a markdown entry should scroll to the specific timekeep position", async () => {
const vault = new MockVault();
const testFile = vault.addFile("test.md", "");
const ref: TimekeepRegistryItemRef = {
file: testFile,
type: TimekeepEntryItemType.MARKDOWN,
position: { startLine: 1, endLine: 2 },
};
const openFile = vi.fn().mockResolvedValue(undefined);
const view = new MockMarkdownView();
const workspace = {
getLeaf() {
return { view, openFile };
},
} as any as Workspace;
await TimekeepRegistry.openItemRef(workspace, ref);
expect(openFile).toHaveBeenCalled();
const line = ref.position.startLine;
expect(view.editor.setCursor).toHaveBeenCalledExactlyOnceWith({
line: line - 1,
ch: 0,
});
expect(view.editor.scrollIntoView).toHaveBeenCalledExactlyOnceWith(
{ from: { line, ch: 0 }, to: { line, ch: 0 } },
true
);
});
});
});

View file

@ -1,7 +1,7 @@
import type { TAbstractFile, Vault } from "obsidian";
import type { TAbstractFile, Vault, Workspace } from "obsidian";
import moment from "moment";
import { Component, TFile } from "obsidian";
import { Component, MarkdownView, TFile } from "obsidian";
import { limitFunction } from "p-limit";
import type { TimekeepSettings } from "@/settings";
@ -357,4 +357,20 @@ export class TimekeepRegistry extends Component {
return null;
}
static async openItemRef(workspace: Workspace, ref: TimekeepRegistryItemRef) {
const leaf = workspace.getLeaf();
await leaf.openFile(ref.file);
const view = leaf.view;
if (view instanceof MarkdownView && ref.type === TimekeepEntryItemType.MARKDOWN) {
const editor = view.editor;
const line = ref.position.startLine;
// Focus the line we opened to
editor.setCursor({ line: Math.max(line - 1, 0), ch: 0 });
editor.scrollIntoView({ from: { line, ch: 0 }, to: { line, ch: 0 } }, true);
}
}
}