Merge pull request #86 from jacobtread/85-feature-option-for-how-notes-open-when-opened-from-status-bar-timers

feat: status item allow opening in a new tab
This commit is contained in:
Jacob 2026-05-09 10:29:25 +12:00 committed by GitHub
commit bb1aeba57f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 197 additions and 41 deletions

View file

@ -523,7 +523,18 @@ export const MockNotice = vi.fn(
}
);
export class MockMarkdownView {
export class MockWorkspaceLeaf {
view: unknown;
openFile = vi.fn().mockResolvedValue(undefined);
}
export class MockFileView {
file: TFile | null = null;
}
export class MockEditableFileView extends MockFileView {}
export class MockMarkdownView extends MockEditableFileView {
editor = {
setCursor: vi.fn(),
scrollIntoView: vi.fn(),

View file

@ -3,6 +3,8 @@ import { afterEach, beforeEach, vi } from "vitest";
import {
MockButtonComponent,
MockComponent,
MockEditableFileView,
MockFileView,
MockMarkdownView,
MockModal,
MockNotice,
@ -43,5 +45,7 @@ vi.mock("obsidian", () => {
ButtonComponent: MockButtonComponent,
MarkdownView: MockMarkdownView,
Platform: MockPlatform,
FileView: MockFileView,
EditableFileView: MockEditableFileView
};
});

View file

@ -68,7 +68,11 @@ export class TimekeepApi {
file: TFile,
cached?: boolean
) => Promise<TimekeepRegistryEntry | null>;
openItemRef: (workspace: Workspace, ref: TimekeepRegistryItemRef) => Promise<void>;
openItemRef: (
workspace: Workspace,
ref: TimekeepRegistryItemRef,
newTab?: boolean
) => Promise<void>;
getRunningEntries: (entries: TimekeepRegistryEntry[]) => TimekeepRunningEntry[];
constructor(

View file

@ -64,7 +64,6 @@ export class TimesheetStatusBar extends Component {
// No need to subscribe to settings, this component is recreated when settings changes
const settings = this.settings.getState();
const entries = this.registry.entries.getState();
const runningEntries = TimekeepRegistry.getRunningEntries(entries);
@ -86,9 +85,9 @@ export class TimesheetStatusBar extends Component {
wrapperEl,
this.app,
this.registry,
settings,
entry,
ref,
settings.statusBarShowFolderPath
ref
);
this.items.push(item);
item.load();

View file

@ -55,12 +55,12 @@ describe("TimesheetStatusBarItem", () => {
containerEl,
app,
registry,
{ ...defaultSettings, statusBarShowFolderPath: false },
entry,
{
file,
type: TimekeepEntryItemType.FILE,
},
false
}
);
expect(() => component.load()).not.toThrow();
@ -72,12 +72,13 @@ describe("TimesheetStatusBarItem", () => {
containerEl,
app,
registry,
{ ...defaultSettings, statusBarShowFolderPath: false },
entry,
{
file,
type: TimekeepEntryItemType.FILE,
},
false
}
);
const onStop = vi.spyOn(component, "onStop");
@ -103,12 +104,13 @@ describe("TimesheetStatusBarItem", () => {
containerEl,
app,
registry,
{ ...defaultSettings, statusBarShowFolderPath: false },
entry,
{
file,
type: TimekeepEntryItemType.FILE,
},
false
}
);
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
const onStop = vi.spyOn(component, "onStop");
@ -126,12 +128,13 @@ describe("TimesheetStatusBarItem", () => {
containerEl,
app,
registry,
{ ...defaultSettings, statusBarShowFolderPath: false },
entry,
{
file,
type: TimekeepEntryItemType.FILE,
},
false
}
);
const onOpen = vi.spyOn(component, "onOpen");
@ -153,12 +156,13 @@ describe("TimesheetStatusBarItem", () => {
containerEl,
app,
registry,
{ ...defaultSettings, statusBarShowFolderPath: true },
entry,
{
file,
type: TimekeepEntryItemType.FILE,
},
true
}
);
component.load();
@ -175,12 +179,12 @@ describe("TimesheetStatusBarItem", () => {
containerEl,
app,
registry,
{ ...defaultSettings, statusBarShowFolderPath: false },
entry,
{
file,
type: TimekeepEntryItemType.FILE,
},
false
}
);
component.load();

View file

@ -1,5 +1,7 @@
import { App } from "obsidian";
import { TimekeepSettings } from "@/settings";
import { DomComponent } from "@/components/DomComponent";
import { createObsidianIcon } from "@/components/obsidianIcon";
import { TimesheetEntryDuration } from "@/components/TimesheetEntryDuration";
@ -19,28 +21,27 @@ export class TimesheetStatusBarItem extends DomComponent {
entry: TimeEntry;
/** The registry for timekeeps */
registry: TimekeepRegistry;
/** The current timekeep settings */
settings: TimekeepSettings;
/** The reference to the item */
ref: TimekeepRegistryItemRef;
/** Whether to show the folder path in the name */
showFolderPath: boolean;
constructor(
containerEl: HTMLElement,
app: App,
registry: TimekeepRegistry,
settings: TimekeepSettings,
entry: TimeEntry,
ref: TimekeepRegistryItemRef,
showFolderPath: boolean
ref: TimekeepRegistryItemRef
) {
super(containerEl);
this.app = app;
this.registry = registry;
this.settings = settings;
this.entry = entry;
this.ref = ref;
this.showFolderPath = showFolderPath;
}
onload(): void {
@ -88,7 +89,7 @@ export class TimesheetStatusBarItem extends DomComponent {
}
getFolderPath() {
if (!this.showFolderPath) return "";
if (!this.settings.statusBarShowFolderPath) return "";
const path = this.ref.file.path;
const parts = path.split("/");
if (parts.length < 2) return "";
@ -104,6 +105,10 @@ export class TimesheetStatusBarItem extends DomComponent {
}
async onOpen() {
await TimekeepRegistry.openItemRef(this.app.workspace, this.ref);
await TimekeepRegistry.openItemRef(
this.app.workspace,
this.ref,
this.settings.statusBarItemOpenNewTab
);
}
}

View file

@ -4,7 +4,7 @@ import moment from "moment";
import { v4 } from "uuid";
import { describe, vi, it, expect } from "vitest";
import { MockMarkdownView, MockVault } from "@/__mocks__/obsidian";
import { MockMarkdownView, MockVault, MockWorkspaceLeaf } from "@/__mocks__/obsidian";
import { defaultSettings, TimekeepSettings } from "@/settings";
import { createStore } from "@/store";
import { createCodeBlock } from "@/utils/codeblock";
@ -761,19 +761,96 @@ describe("TimekeepRegistry", () => {
type: TimekeepEntryItemType.FILE,
};
const openFile = vi.fn().mockResolvedValue(undefined);
const leaf = new MockWorkspaceLeaf();
leaf.view = new MockMarkdownView();
const workspace = {
getLeaf() {
const view = new MockMarkdownView();
(view as any).openFile = openFile;
return view;
return leaf;
},
getLeavesOfType: vi.fn(() => []),
} as any as Workspace;
await TimekeepRegistry.openItemRef(workspace, ref);
expect(openFile).toHaveBeenCalled();
expect(leaf.openFile).toHaveBeenCalled();
});
it("should attempt to open the file in a new tab", async () => {
const vault = new MockVault();
const testFile = vault.addFile("test.md", "");
const ref: TimekeepRegistryItemRef = {
file: testFile,
type: TimekeepEntryItemType.FILE,
};
const leaf = new MockWorkspaceLeaf();
leaf.view = new MockMarkdownView();
const fakeLeaf = () => {
const leaf = new MockWorkspaceLeaf();
const view = new MockMarkdownView();
leaf.view = view;
return leaf;
};
const getLeavesOfType = vi.fn(() => {
return [fakeLeaf(), fakeLeaf(), fakeLeaf(), new MockMarkdownView()];
});
const getLeaf = vi.fn(() => {
return leaf;
});
const workspace = {
getLeaf,
getLeavesOfType,
} as any as Workspace;
await TimekeepRegistry.openItemRef(workspace, ref, true);
expect(getLeaf).toHaveBeenCalledWith("tab");
expect(leaf.openFile).toHaveBeenCalled();
});
it("should focus the existing file instead of opening a new tab if one is present", async () => {
const vault = new MockVault();
const testFile = vault.addFile("test.timekeep", "");
const ref: TimekeepRegistryItemRef = {
file: testFile,
type: TimekeepEntryItemType.FILE,
};
const existingLeaf = new MockWorkspaceLeaf();
const view = new MockMarkdownView();
view.file = testFile;
existingLeaf.view = view;
const revealLeaf = vi.fn().mockResolvedValue(undefined);
const setActiveLeaf = vi.fn().mockResolvedValue(undefined);
const getLeaf = vi.fn(() => {
const existingLeaf = new MockWorkspaceLeaf();
const view = new MockMarkdownView();
existingLeaf.view = view;
return existingLeaf;
});
const getLeavesOfType = vi.fn(() => {
return [existingLeaf];
});
const workspace = {
getLeaf,
getLeavesOfType,
revealLeaf,
setActiveLeaf,
} as any as Workspace;
await TimekeepRegistry.openItemRef(workspace, ref, true);
expect(getLeavesOfType).toHaveBeenCalledWith("timekeep");
expect(getLeaf).not.toHaveBeenCalled();
expect(existingLeaf.openFile).not.toHaveBeenCalled();
expect(revealLeaf).toHaveBeenCalledWith(existingLeaf);
expect(setActiveLeaf).toHaveBeenCalledWith(existingLeaf, { focus: true });
});
it("opening a markdown entry should scroll to the specific timekeep position", async () => {
@ -785,18 +862,19 @@ describe("TimekeepRegistry", () => {
position: { startLine: 1, endLine: 2 },
};
const openFile = vi.fn().mockResolvedValue(undefined);
const leaf = new MockWorkspaceLeaf();
const view = new MockMarkdownView();
leaf.view = view;
const workspace = {
getLeaf() {
return { view, openFile };
return leaf;
},
getLeavesOfType: vi.fn(() => []),
} as any as Workspace;
await TimekeepRegistry.openItemRef(workspace, ref);
expect(openFile).toHaveBeenCalled();
expect(leaf.openFile).toHaveBeenCalled();
const line = ref.position.startLine;

View file

@ -1,7 +1,7 @@
import type { EventRef, TAbstractFile, Vault, Workspace } from "obsidian";
import type { EventRef, TAbstractFile, Vault, Workspace, WorkspaceLeaf } from "obsidian";
import moment from "moment";
import { Component, MarkdownView, TFile } from "obsidian";
import { EditableFileView, Component, MarkdownView, TFile } from "obsidian";
import { limitFunction } from "p-limit";
import type { TimekeepSettings } from "@/settings";
@ -380,6 +380,28 @@ export class TimekeepRegistry extends Component {
return null;
}
/**
* Checks the workspace leaves to see if the provided timekeep item
* ref is already open in a file
*
* @param workspace The workspace to check within
* @param ref The reference to the timekeep
* @returns The existing leaf if found
*/
private static getExistingRefLeaf(workspace: Workspace, ref: TimekeepRegistryItemRef) {
const leavesType = ref.type === TimekeepEntryItemType.FILE ? "timekeep" : "markdown";
const leaves = workspace.getLeavesOfType(leavesType);
for (const leaf of leaves) {
const view = leaf.view;
if (view instanceof EditableFileView && view.file && view.file.path === ref.file.path) {
return leaf;
}
}
return null;
}
/**
* Opens a timekeep by reference to the file and the
* line within the file for markdown timekeep items
@ -387,9 +409,23 @@ export class TimekeepRegistry extends Component {
* @param workspace The workspace to open the file with
* @param ref The reference to the timekeep
*/
static async openItemRef(workspace: Workspace, ref: TimekeepRegistryItemRef) {
const leaf = workspace.getLeaf();
await leaf.openFile(ref.file);
static async openItemRef(
workspace: Workspace,
ref: TimekeepRegistryItemRef,
newTab: boolean = false
) {
let leaf: WorkspaceLeaf | null = TimekeepRegistry.getExistingRefLeaf(workspace, ref);
// Focus and reveal the existing leaf if found
if (leaf !== null) {
workspace.setActiveLeaf(leaf, { focus: true });
await workspace.revealLeaf(leaf);
}
// Open a new leaf for the ref
else {
leaf = workspace.getLeaf(newTab ? "tab" : false);
await leaf.openFile(ref.file);
}
const view = leaf.view;

View file

@ -443,7 +443,7 @@ export class TimekeepSettingsTab extends PluginSettingTab {
});
new Setting(this.containerEl)
.setName("Show Folder Path")
.setName("Show folder path")
.setDesc(
'Whether to include the folder path of the file in the status item (i.e "Path/To/Entry: Block 1: 3h 5min 30s").'
)
@ -457,6 +457,19 @@ export class TimekeepSettingsTab extends PluginSettingTab {
});
});
new Setting(this.containerEl)
.setName("Open in new tab")
.setDesc('Whether to open the file in a new tab after clicking a status item").')
.addToggle((t) => {
t.setValue(settings.statusBarItemOpenNewTab);
t.onChange((v) => {
this.settingsStore.setState((currentValue) => ({
...currentValue,
statusBarItemOpenNewTab: v,
}));
});
});
// Autocomplete section
new Setting(this.containerEl)
.setName("Autocomplete")

View file

@ -75,6 +75,7 @@ export interface TimekeepSettings {
statusBarEnabled: boolean;
statusBarShowFolderPath: boolean;
statusBarItemOpenNewTab: boolean;
autocompleteEnabled: boolean;
}
@ -106,6 +107,7 @@ export const defaultSettings: TimekeepSettings = {
statusBarEnabled: true,
statusBarShowFolderPath: true,
statusBarItemOpenNewTab: false,
autocompleteEnabled: true,
};