diff --git a/src/__mocks__/obsidian.ts b/src/__mocks__/obsidian.ts index 01ce910..925324c 100644 --- a/src/__mocks__/obsidian.ts +++ b/src/__mocks__/obsidian.ts @@ -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(), diff --git a/src/__mocks__/setupObsidianMocks.ts b/src/__mocks__/setupObsidianMocks.ts index ddf209c..c991bff 100644 --- a/src/__mocks__/setupObsidianMocks.ts +++ b/src/__mocks__/setupObsidianMocks.ts @@ -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 }; }); diff --git a/src/api.ts b/src/api.ts index 1b3b956..c0e6447 100644 --- a/src/api.ts +++ b/src/api.ts @@ -68,7 +68,11 @@ export class TimekeepApi { file: TFile, cached?: boolean ) => Promise; - openItemRef: (workspace: Workspace, ref: TimekeepRegistryItemRef) => Promise; + openItemRef: ( + workspace: Workspace, + ref: TimekeepRegistryItemRef, + newTab?: boolean + ) => Promise; getRunningEntries: (entries: TimekeepRegistryEntry[]) => TimekeepRunningEntry[]; constructor( diff --git a/src/components/TimesheetStatusBar/TimesheetStatusBar.ts b/src/components/TimesheetStatusBar/TimesheetStatusBar.ts index 63a1d8d..2c1d266 100644 --- a/src/components/TimesheetStatusBar/TimesheetStatusBar.ts +++ b/src/components/TimesheetStatusBar/TimesheetStatusBar.ts @@ -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(); diff --git a/src/components/TimesheetStatusBar/TimesheetStatusBarItem.test.ts b/src/components/TimesheetStatusBar/TimesheetStatusBarItem.test.ts index fd9b568..30b6d53 100644 --- a/src/components/TimesheetStatusBar/TimesheetStatusBarItem.test.ts +++ b/src/components/TimesheetStatusBar/TimesheetStatusBarItem.test.ts @@ -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(); diff --git a/src/components/TimesheetStatusBar/TimesheetStatusBarItem.ts b/src/components/TimesheetStatusBar/TimesheetStatusBarItem.ts index 3896bce..5a9ed89 100644 --- a/src/components/TimesheetStatusBar/TimesheetStatusBarItem.ts +++ b/src/components/TimesheetStatusBar/TimesheetStatusBarItem.ts @@ -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 + ); } } diff --git a/src/service/registry.test.ts b/src/service/registry.test.ts index 083984c..6da8baf 100644 --- a/src/service/registry.test.ts +++ b/src/service/registry.test.ts @@ -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; diff --git a/src/service/registry.ts b/src/service/registry.ts index 4f7ae9c..3570a53 100644 --- a/src/service/registry.ts +++ b/src/service/registry.ts @@ -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; diff --git a/src/settings-tab.ts b/src/settings-tab.ts index 3236671..77c24a2 100644 --- a/src/settings-tab.ts +++ b/src/settings-tab.ts @@ -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") diff --git a/src/settings.ts b/src/settings.ts index 90151e5..cf741ca 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -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, };