mirror of
https://github.com/jacobtread/obsidian-timekeep.git
synced 2026-07-22 10:10:27 +00:00
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:
commit
bb1aeba57f
10 changed files with 197 additions and 41 deletions
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue