mirror of
https://github.com/logancyang/obsidian-copilot.git
synced 2026-07-22 07:50:24 +00:00
feat(agent-home): surface Relevant Notes as a shelf tab (#2668)
* Revert "Revert "feat(agent-home): surface Relevant Notes as a home-shelf tab …"
This reverts commit 982a2462f4.
* fix(agent-home): size relevant notes shelf correctly
* fix(agent-home): address relevant notes review feedback
* test(relevant-notes): cover shelf navigation leaf selection
* fix(relevant-notes): always open notes in new tabs
This commit is contained in:
parent
2afc0949f3
commit
d8dfaa6c6b
20 changed files with 640 additions and 98 deletions
|
|
@ -141,7 +141,7 @@ When starting a new chat, Copilot may show suggested prompts based on your activ
|
|||
|
||||
Copilot can display a list of notes related to your currently active note. This helps surface notes you might want to reference without manually searching. Each note can be opened, dragged in as a wikilink, or sent to the open chat with **Add to Chat**.
|
||||
|
||||
Relevant Notes lives in its own pane. Open it from the command palette: **Open Relevant Notes**. The pane tracks whichever note you're viewing.
|
||||
On Agent Home, Relevant Notes appears as a shelf tab when the dedicated pane is closed. Notes opened from this shelf use a separate tab so Agent Home and its chat stay available. You can also select **Open pane** in the shelf or run **Open Relevant Notes** from the command palette to keep Relevant Notes beside your conversation. While that pane is open, the duplicate Agent Home tab is hidden. Both surfaces track whichever note you're viewing.
|
||||
|
||||
## Saving a Chat Manually
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,10 @@ module.exports = {
|
|||
// jsdom; Jest can't parse ESM without extra config, so point at the CJS
|
||||
// build it ships under dist/.
|
||||
"^yaml$": "<rootDir>/node_modules/yaml/dist/index.js",
|
||||
// @orama/orama resolves to its ESM "browser" entry under jsdom, which Jest
|
||||
// can't parse; point at the CJS build it ships under dist/commonjs/ (same
|
||||
// reason as yaml above).
|
||||
"^@orama/orama$": "<rootDir>/node_modules/@orama/orama/dist/commonjs/index.js",
|
||||
"^@agentclientprotocol/sdk$": "<rootDir>/__mocks__/@agentclientprotocol/sdk.js",
|
||||
"^@anthropic-ai/claude-agent-sdk$": "<rootDir>/__mocks__/@anthropic-ai/claude-agent-sdk.js",
|
||||
// react-resizable-panels is ESM-only with no CJS build to point at; stub it.
|
||||
|
|
|
|||
|
|
@ -14,7 +14,14 @@ import { AgentWelcomeCard } from "@/agentMode/ui/AgentWelcomeCard";
|
|||
import { CopilotBrandIcon } from "@/agentMode/ui/CopilotBrandIcon";
|
||||
import { AgentHomeShelf, type AgentHomeShelfSection } from "@/agentMode/ui/AgentHomeShelf";
|
||||
import { GlobalRecentChatsSection } from "@/agentMode/ui/GlobalRecentChatsSection";
|
||||
import {
|
||||
getHomeShelfTab,
|
||||
setHomeShelfTab,
|
||||
HOME_SHELF_TAB_STORAGE_KEY,
|
||||
} from "@/agentMode/ui/homeShelfPrefs";
|
||||
import { ProjectPickerList } from "@/agentMode/ui/ProjectPickerList";
|
||||
import { RelevantNotesShelfPanel } from "@/agentMode/ui/RelevantNotesShelfPanel";
|
||||
import { useRelevantNotesPaneOpen } from "@/agentMode/ui/useRelevantNotesPaneOpen";
|
||||
import { useAgentChatRuntimeState } from "@/agentMode/ui/hooks/useAgentChatRuntimeState";
|
||||
import { useAgentHistoryControls } from "@/agentMode/ui/hooks/useAgentHistoryControls";
|
||||
import { useAgentInputDrafts } from "@/agentMode/ui/hooks/useAgentInputDrafts";
|
||||
|
|
@ -45,7 +52,7 @@ import { getProjectLandingCaptureSignature } from "@/projects/projectContextSign
|
|||
import { getCachedProjectRecordById, useProjects } from "@/projects/state";
|
||||
import { getSettings, settingsStore, updateSetting, useSettingsValue } from "@/settings/model";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { Files, Folder, MessageSquare } from "lucide-react";
|
||||
import { FileSearch, Files, Folder, MessageSquare } from "lucide-react";
|
||||
import { Notice } from "obsidian";
|
||||
import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
|
|
@ -86,6 +93,7 @@ const AgentHomeInternal: React.FC<AgentHomeProps> = ({
|
|||
const settings = useSettingsValue();
|
||||
const eventTarget = useContext(EventTargetContext);
|
||||
const chatInput = useChatInput();
|
||||
const isRelevantNotesPaneOpen = useRelevantNotesPaneOpen(app);
|
||||
|
||||
// Place the caret in the composer when the agent view opens so the user can
|
||||
// type immediately. AgentHome only mounts once preload settles and a session
|
||||
|
|
@ -508,9 +516,17 @@ const AgentHomeInternal: React.FC<AgentHomeProps> = ({
|
|||
// instance-level UI memory: wherever the user left the global shelf, they
|
||||
// return to it (entering a project from the Projects tab and backing out
|
||||
// lands on Projects again instead of snapping to Recent Chats). AgentHome
|
||||
// stays mounted across those switches, so the state survives. null = nothing
|
||||
// picked yet → the shelf resolves to its first selectable tab.
|
||||
const [globalShelfTab, setGlobalShelfTab] = useState<string | null>(null);
|
||||
// stays mounted across those switches, so the state survives. On top of that
|
||||
// in-instance memory we seed from (and write back to) device-local storage so
|
||||
// the choice also survives a full remount / reload. null = nothing picked yet
|
||||
// → the shelf resolves to its first selectable tab.
|
||||
const [globalShelfTab, setGlobalShelfTabState] = useState<string | null>(() =>
|
||||
getHomeShelfTab(HOME_SHELF_TAB_STORAGE_KEY)
|
||||
);
|
||||
const setGlobalShelfTab = useCallback((id: string) => {
|
||||
setGlobalShelfTabState(id);
|
||||
setHomeShelfTab(HOME_SHELF_TAB_STORAGE_KEY, id);
|
||||
}, []);
|
||||
|
||||
// Chip-shelf sections for the landing. Each body renders lazily (only the open
|
||||
// section is mounted), so these render closures are cheap to recreate.
|
||||
|
|
@ -534,6 +550,23 @@ const AgentHomeInternal: React.FC<AgentHomeProps> = ({
|
|||
/>
|
||||
),
|
||||
},
|
||||
// Hidden while the dedicated Relevant Notes pane is open, so the shelf
|
||||
// doesn't duplicate a surface the user already has pinned alongside chat.
|
||||
...(isRelevantNotesPaneOpen
|
||||
? []
|
||||
: [
|
||||
{
|
||||
id: "relevant-notes",
|
||||
icon: <FileSearch className="tw-size-4" />,
|
||||
title: "Relevant Notes",
|
||||
renderBody: () => (
|
||||
<RelevantNotesShelfPanel
|
||||
onPopOut={() => void plugin.activateRelevantNotesView()}
|
||||
onAddToChat={(text) => void plugin.insertTextIntoActiveChat(text)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]),
|
||||
{
|
||||
id: "projects",
|
||||
icon: <Folder className="tw-size-4" />,
|
||||
|
|
@ -566,6 +599,8 @@ const AgentHomeInternal: React.FC<AgentHomeProps> = ({
|
|||
handleLoadChatHistory,
|
||||
runningChatIds,
|
||||
attentionChatIds,
|
||||
isRelevantNotesPaneOpen,
|
||||
plugin,
|
||||
]
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -46,48 +46,73 @@ const projectsDisabled: AgentHomeShelfSection = {
|
|||
renderBody: () => <div>PROJECTS BODY</div>,
|
||||
};
|
||||
|
||||
describe("AgentHomeShelf with a disabled section", () => {
|
||||
it("activates the first selectable section, not the disabled one", () => {
|
||||
renderShelf([chats, projectsDisabled]);
|
||||
expect(screen.queryByText("CHATS BODY")).not.toBeNull();
|
||||
// The disabled section's body never mounts.
|
||||
expect(screen.queryByText("PROJECTS BODY")).toBeNull();
|
||||
});
|
||||
describe("AgentHomeShelf", () => {
|
||||
describe("AgentHomeShelf()", () => {
|
||||
it("activates the first selectable section when another section is disabled", () => {
|
||||
renderShelf([chats, projectsDisabled]);
|
||||
expect(screen.queryByText("CHATS BODY")).not.toBeNull();
|
||||
expect(screen.queryByText("PROJECTS BODY")).toBeNull();
|
||||
});
|
||||
|
||||
it("marks the disabled tab aria-disabled and hides its count", () => {
|
||||
renderShelf([chats, projectsDisabled]);
|
||||
const projectsTab = screen.getByRole("tab", { name: /Projects/ });
|
||||
expect(projectsTab.getAttribute("aria-disabled")).toBe("true");
|
||||
expect(projectsTab.textContent ?? "").not.toContain("5");
|
||||
});
|
||||
it("marks a disabled tab aria-disabled and hides its count", () => {
|
||||
renderShelf([chats, projectsDisabled]);
|
||||
const projectsTab = screen.getByRole("tab", { name: /Projects/ });
|
||||
expect(projectsTab.getAttribute("aria-disabled")).toBe("true");
|
||||
expect(projectsTab.textContent ?? "").not.toContain("5");
|
||||
});
|
||||
|
||||
it("does not activate the disabled tab on click", () => {
|
||||
renderShelf([chats, projectsDisabled]);
|
||||
fireEvent.click(screen.getByRole("tab", { name: /Projects/ }));
|
||||
expect(screen.queryByText("PROJECTS BODY")).toBeNull();
|
||||
expect(screen.queryByText("CHATS BODY")).not.toBeNull();
|
||||
it("keeps the current section active when a disabled tab is clicked", () => {
|
||||
renderShelf([chats, projectsDisabled]);
|
||||
fireEvent.click(screen.getByRole("tab", { name: /Projects/ }));
|
||||
expect(screen.queryByText("PROJECTS BODY")).toBeNull();
|
||||
expect(screen.queryByText("CHATS BODY")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("renders the parent-selected section in controlled mode", () => {
|
||||
renderShelf([chats, projectsEnabled], { activeSectionId: "projects" });
|
||||
expect(screen.queryByText("PROJECTS BODY")).not.toBeNull();
|
||||
expect(screen.queryByText("CHATS BODY")).toBeNull();
|
||||
});
|
||||
|
||||
it("falls back to the first selectable section when controlled mode has no selection", () => {
|
||||
renderShelf([chats, projectsEnabled], { activeSectionId: null });
|
||||
expect(screen.queryByText("CHATS BODY")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("reports controlled clicks without switching sections until the parent updates", () => {
|
||||
const onSectionSelect = jest.fn();
|
||||
renderShelf([chats, projectsEnabled], { activeSectionId: "chats", onSectionSelect });
|
||||
fireEvent.click(screen.getByRole("tab", { name: /Projects/ }));
|
||||
expect(onSectionSelect).toHaveBeenCalledWith("projects");
|
||||
expect(screen.queryByText("CHATS BODY")).not.toBeNull();
|
||||
expect(screen.queryByText("PROJECTS BODY")).toBeNull();
|
||||
});
|
||||
|
||||
it("hides the count badge when the count is undefined", () => {
|
||||
renderShelf([withCount(undefined)]);
|
||||
expect(screen.getByRole("tab", { name: /Recent Chats/ }).textContent ?? "").not.toMatch(/\d/);
|
||||
});
|
||||
|
||||
it("hides the count badge when the count is zero", () => {
|
||||
renderShelf([withCount(0)]);
|
||||
expect(screen.getByRole("tab", { name: /Recent Chats/ }).textContent ?? "").not.toContain(
|
||||
"0"
|
||||
);
|
||||
});
|
||||
|
||||
it("shows the count badge when the count is positive", () => {
|
||||
renderShelf([withCount(3)]);
|
||||
expect(screen.getByRole("tab", { name: /Recent Chats/ }).textContent ?? "").toContain("3");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("AgentHomeShelf controlled mode", () => {
|
||||
it("renders the parent-selected section's body", () => {
|
||||
renderShelf([chats, projectsEnabled], { activeSectionId: "projects" });
|
||||
expect(screen.queryByText("PROJECTS BODY")).not.toBeNull();
|
||||
expect(screen.queryByText("CHATS BODY")).toBeNull();
|
||||
});
|
||||
|
||||
it("falls back to the first selectable section when nothing is picked yet (null)", () => {
|
||||
renderShelf([chats, projectsEnabled], { activeSectionId: null });
|
||||
expect(screen.queryByText("CHATS BODY")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("reports clicks via onSectionSelect instead of switching on its own", () => {
|
||||
const onSectionSelect = jest.fn();
|
||||
renderShelf([chats, projectsEnabled], { activeSectionId: "chats", onSectionSelect });
|
||||
fireEvent.click(screen.getByRole("tab", { name: /Projects/ }));
|
||||
expect(onSectionSelect).toHaveBeenCalledWith("projects");
|
||||
// Controlled: the body only changes when the parent updates the prop.
|
||||
expect(screen.queryByText("CHATS BODY")).not.toBeNull();
|
||||
expect(screen.queryByText("PROJECTS BODY")).toBeNull();
|
||||
});
|
||||
});
|
||||
function withCount(count?: number): AgentHomeShelfSection {
|
||||
return {
|
||||
id: "chats",
|
||||
icon: <span />,
|
||||
title: "Recent Chats",
|
||||
count,
|
||||
renderBody: () => <div>CHATS BODY</div>,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,8 @@ export interface AgentHomeShelfSection {
|
|||
/** Leading type icon for the tab. */
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
count: number;
|
||||
/** Optional tally shown next to the title; the badge renders only when > 0. */
|
||||
count?: number;
|
||||
/** Rendered into the panel while this tab is the selected one. */
|
||||
renderBody: () => React.ReactNode;
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ interface AgentHomeTabProps {
|
|||
/** Leading type icon (sized by the caller, typically `tw-size-4`). */
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
count: number;
|
||||
count?: number;
|
||||
/** Whether this tab is the selected one. */
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
|
|
@ -68,7 +68,7 @@ export const AgentHomeTab = memo(function AgentHomeTab({
|
|||
>
|
||||
<span className="tw-flex tw-shrink-0 tw-items-center tw-text-muted">{icon}</span>
|
||||
<span>{title}</span>
|
||||
{!disabled && (
|
||||
{!disabled && typeof count === "number" && count > 0 && (
|
||||
<span className={cn("tw-text-ui-smaller", active ? "tw-text-muted" : "tw-text-faint")}>
|
||||
{count}
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { attachChatViewLayoutObservers } from "@/components/chat-components/atta
|
|||
import { CHAT_AGENT_VIEWTYPE, COPILOT_AGENT_ICON_ID } from "@/constants";
|
||||
import { ChatViewEventTarget, EventTargetContext } from "@/context";
|
||||
import CopilotPlugin from "@/main";
|
||||
import { registerActiveLeafChangeBridge } from "@/utils/registerActiveLeafChangeBridge";
|
||||
import { mountPluginViewRoot, type PluginViewRootHandle } from "@/utils/react/mountPluginViewRoot";
|
||||
import * as Tooltip from "@radix-ui/react-tooltip";
|
||||
import { ItemView, WorkspaceLeaf } from "obsidian";
|
||||
|
|
@ -46,6 +47,8 @@ export default class CopilotAgentView extends ItemView {
|
|||
const observers = attachChatViewLayoutObservers(this.containerEl);
|
||||
this.disposeLayoutObservers = observers.dispose;
|
||||
|
||||
registerActiveLeafChangeBridge(this, this.eventTarget);
|
||||
|
||||
this.registerEvent(
|
||||
this.app.workspace.on("layout-change", () => {
|
||||
window.requestAnimationFrame(() => observers.rebindDrawerObserver());
|
||||
|
|
|
|||
34
src/agentMode/ui/RelevantNotesShelfPanel.test.tsx
Normal file
34
src/agentMode/ui/RelevantNotesShelfPanel.test.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { RelevantNotesShelfPanel } from "@/agentMode/ui/RelevantNotesShelfPanel";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import React from "react";
|
||||
|
||||
jest.mock("@/agentMode/ui/homeShelfPrefs", () => ({
|
||||
dismissPopOutHint: jest.fn(),
|
||||
isPopOutHintDismissed: () => true,
|
||||
}));
|
||||
|
||||
jest.mock("@/components/chat-components/RelevantNotes", () => ({
|
||||
RelevantNotes: ({ className }: { className?: string }) => (
|
||||
<div data-testid="relevant-notes" className={className} />
|
||||
),
|
||||
}));
|
||||
|
||||
describe("RelevantNotesShelfPanel", () => {
|
||||
describe("RelevantNotesShelfPanel()", () => {
|
||||
it("fills the Agent Home shelf and reserves vertical spacing for empty states", () => {
|
||||
const { container } = render(
|
||||
<RelevantNotesShelfPanel onPopOut={jest.fn()} onAddToChat={jest.fn()} />
|
||||
);
|
||||
|
||||
const panel = container.firstElementChild as HTMLElement;
|
||||
const relevantNotes = screen.getByTestId("relevant-notes");
|
||||
const content = relevantNotes.parentElement as HTMLElement;
|
||||
|
||||
expect(panel.classList.contains("tw-flex-1")).toBe(true);
|
||||
expect(panel.classList.contains("tw-min-h-0")).toBe(true);
|
||||
expect(content.classList.contains("tw-flex")).toBe(true);
|
||||
expect(content.classList.contains("tw-flex-col")).toBe(true);
|
||||
expect(relevantNotes.className).toContain("[&>[data-relevant-notes-empty-state]]:tw-py-6");
|
||||
});
|
||||
});
|
||||
});
|
||||
69
src/agentMode/ui/RelevantNotesShelfPanel.tsx
Normal file
69
src/agentMode/ui/RelevantNotesShelfPanel.tsx
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import { dismissPopOutHint, isPopOutHintDismissed } from "@/agentMode/ui/homeShelfPrefs";
|
||||
import { RelevantNotes } from "@/components/chat-components/RelevantNotes";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ExternalLink, X } from "lucide-react";
|
||||
import React, { useState } from "react";
|
||||
|
||||
interface RelevantNotesShelfPanelProps {
|
||||
/** Open the dedicated Relevant Notes pane (the shelf is transient). */
|
||||
onPopOut: () => void;
|
||||
/** Insert text (a `[[wikilink]]`) into the target chat input. */
|
||||
onAddToChat: (text: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Relevant Notes rendered inside the Agent Home shelf. The shelf is transient
|
||||
* (it collapses as you chat), so a one-time, dismissible hint nudges the user
|
||||
* toward the dedicated pane that persists alongside the conversation. Assumes
|
||||
* the surrounding agent view already provides AppContext + EventTargetContext.
|
||||
*/
|
||||
export function RelevantNotesShelfPanel({ onPopOut, onAddToChat }: RelevantNotesShelfPanelProps) {
|
||||
const [hintDismissed, setHintDismissed] = useState(() => isPopOutHintDismissed());
|
||||
|
||||
const handleDismiss = () => {
|
||||
dismissPopOutHint();
|
||||
setHintDismissed(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("tw-flex tw-min-h-0 tw-w-full tw-flex-1 tw-flex-col tw-overflow-hidden")}>
|
||||
{!hintDismissed && (
|
||||
<div
|
||||
className={cn(
|
||||
"tw-flex tw-shrink-0 tw-items-center tw-gap-2 tw-rounded-md tw-border tw-border-solid",
|
||||
"tw-border-border tw-bg-secondary tw-px-2 tw-py-1.5 tw-text-ui-smaller tw-text-muted"
|
||||
)}
|
||||
>
|
||||
<span className="tw-min-w-0 tw-flex-1">
|
||||
Open Relevant Notes in its own pane to keep it while you chat.
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost2"
|
||||
size="fit"
|
||||
className={cn("tw-shrink-0 tw-text-muted", "hover:tw-text-normal")}
|
||||
onClick={onPopOut}
|
||||
>
|
||||
<ExternalLink className="tw-size-3" />
|
||||
Open pane
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost2"
|
||||
size="fit"
|
||||
className={cn("tw-shrink-0 tw-text-muted", "hover:tw-text-normal")}
|
||||
onClick={handleDismiss}
|
||||
aria-label="Dismiss hint"
|
||||
>
|
||||
<X className="tw-size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className={cn("tw-flex tw-min-h-0 tw-flex-1 tw-flex-col")}>
|
||||
<RelevantNotes
|
||||
className={cn("[&>[data-relevant-notes-empty-state]]:tw-py-6")}
|
||||
onAddToChat={onAddToChat}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
94
src/agentMode/ui/homeShelfPrefs.test.ts
Normal file
94
src/agentMode/ui/homeShelfPrefs.test.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import {
|
||||
dismissPopOutHint,
|
||||
getHomeShelfTab,
|
||||
isPopOutHintDismissed,
|
||||
setHomeShelfTab,
|
||||
} from "@/agentMode/ui/homeShelfPrefs";
|
||||
import { logWarn } from "@/logger";
|
||||
|
||||
jest.mock("@/logger", () => ({
|
||||
logWarn: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockLogWarn = logWarn as jest.MockedFunction<typeof logWarn>;
|
||||
|
||||
describe("homeShelfPrefs", () => {
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
describe("getHomeShelfTab()", () => {
|
||||
it("returns null when no tab has been stored", () => {
|
||||
expect(getHomeShelfTab("shelf-key")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns the stored tab id", () => {
|
||||
window.localStorage.setItem("shelf-key", "projects");
|
||||
|
||||
expect(getHomeShelfTab("shelf-key")).toBe("projects");
|
||||
});
|
||||
|
||||
it("returns null when storage cannot be read", () => {
|
||||
jest.spyOn(Storage.prototype, "getItem").mockImplementation(() => {
|
||||
throw new Error("storage disabled");
|
||||
});
|
||||
|
||||
expect(getHomeShelfTab("shelf-key")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("setHomeShelfTab()", () => {
|
||||
it("persists the selected tab id", () => {
|
||||
setHomeShelfTab("shelf-key", "projects");
|
||||
|
||||
expect(window.localStorage.getItem("shelf-key")).toBe("projects");
|
||||
});
|
||||
|
||||
it("does not throw when storage cannot be written", () => {
|
||||
jest.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
|
||||
throw new Error("storage disabled");
|
||||
});
|
||||
|
||||
expect(() => setHomeShelfTab("shelf-key", "projects")).not.toThrow();
|
||||
expect(mockLogWarn).toHaveBeenCalledWith(
|
||||
"Failed to persist home shelf tab",
|
||||
expect.any(Error)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isPopOutHintDismissed()", () => {
|
||||
it("defaults to false when no dismissal has been stored", () => {
|
||||
expect(isPopOutHintDismissed()).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when storage cannot be read", () => {
|
||||
jest.spyOn(Storage.prototype, "getItem").mockImplementation(() => {
|
||||
throw new Error("storage disabled");
|
||||
});
|
||||
|
||||
expect(isPopOutHintDismissed()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("dismissPopOutHint()", () => {
|
||||
it("persists the dismissed state", () => {
|
||||
dismissPopOutHint();
|
||||
|
||||
expect(isPopOutHintDismissed()).toBe(true);
|
||||
});
|
||||
|
||||
it("does not throw when storage cannot be written", () => {
|
||||
jest.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
|
||||
throw new Error("storage disabled");
|
||||
});
|
||||
|
||||
expect(() => dismissPopOutHint()).not.toThrow();
|
||||
expect(mockLogWarn).toHaveBeenCalledWith(
|
||||
"Failed to persist pop-out hint dismissal",
|
||||
expect.any(Error)
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
53
src/agentMode/ui/homeShelfPrefs.ts
Normal file
53
src/agentMode/ui/homeShelfPrefs.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
/**
|
||||
* Device-local UI preferences for the Agent Home shelf, persisted in
|
||||
* `window.localStorage` (which Obsidian never syncs). Mirrors the storage idiom
|
||||
* in `deviceId.ts`: every access is wrapped in try/catch, reads return a safe
|
||||
* default on failure, and writes swallow errors so a broken-storage device
|
||||
* (disabled / restricted) never crashes the UI — it just loses persistence.
|
||||
*
|
||||
* These are intentionally device-local: which shelf tab you last viewed and
|
||||
* whether you dismissed the pop-out hint are per-device UI state, not content
|
||||
* that should ride a synced `data.json` to your other devices.
|
||||
*/
|
||||
|
||||
import { logWarn } from "@/logger";
|
||||
|
||||
export const HOME_SHELF_TAB_STORAGE_KEY = "copilot:home-shelf-tab:v1";
|
||||
const POPOUT_HINT_DISMISSED_KEY = "copilot:relevant-notes-popout-hint-dismissed:v1";
|
||||
|
||||
/** Read the last-selected shelf tab id, or `null` when unset or storage is unusable. */
|
||||
export function getHomeShelfTab(storageKey: string): string | null {
|
||||
try {
|
||||
const value = window.localStorage.getItem(storageKey);
|
||||
return value && value.length > 0 ? value : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Persist the selected shelf tab id; no-op if storage is unusable. */
|
||||
export function setHomeShelfTab(storageKey: string, id: string): void {
|
||||
try {
|
||||
window.localStorage.setItem(storageKey, id);
|
||||
} catch (e) {
|
||||
logWarn("Failed to persist home shelf tab", e);
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether the user dismissed the Relevant Notes pop-out hint; defaults to false. */
|
||||
export function isPopOutHintDismissed(): boolean {
|
||||
try {
|
||||
return window.localStorage.getItem(POPOUT_HINT_DISMISSED_KEY) === "true";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Mark the Relevant Notes pop-out hint dismissed; no-op if storage is unusable. */
|
||||
export function dismissPopOutHint(): void {
|
||||
try {
|
||||
window.localStorage.setItem(POPOUT_HINT_DISMISSED_KEY, "true");
|
||||
} catch (e) {
|
||||
logWarn("Failed to persist pop-out hint dismissal", e);
|
||||
}
|
||||
}
|
||||
38
src/agentMode/ui/useRelevantNotesPaneOpen.test.tsx
Normal file
38
src/agentMode/ui/useRelevantNotesPaneOpen.test.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { useRelevantNotesPaneOpen } from "@/agentMode/ui/useRelevantNotesPaneOpen";
|
||||
import { RELEVANT_NOTES_VIEWTYPE } from "@/constants";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import type { App, EventRef, WorkspaceLeaf } from "obsidian";
|
||||
|
||||
describe("useRelevantNotesPaneOpen", () => {
|
||||
describe("useRelevantNotesPaneOpen()", () => {
|
||||
it("tracks the initial pane state, layout changes, and removes its listener on unmount", () => {
|
||||
let leaves = [{} as WorkspaceLeaf];
|
||||
let layoutChange: (() => void) | undefined;
|
||||
const ref = {} as EventRef;
|
||||
const workspace = {
|
||||
getLeavesOfType: jest.fn(() => leaves),
|
||||
on: jest.fn((_name: string, callback: () => void) => {
|
||||
layoutChange = callback;
|
||||
return ref;
|
||||
}),
|
||||
offref: jest.fn(),
|
||||
};
|
||||
const app = { workspace } as unknown as App;
|
||||
|
||||
const { result, unmount } = renderHook(() => useRelevantNotesPaneOpen(app));
|
||||
|
||||
expect(result.current).toBe(true);
|
||||
expect(workspace.getLeavesOfType).toHaveBeenCalledWith(RELEVANT_NOTES_VIEWTYPE);
|
||||
expect(workspace.on).toHaveBeenCalledWith("layout-change", expect.any(Function));
|
||||
|
||||
leaves = [];
|
||||
act(() => layoutChange?.());
|
||||
|
||||
expect(result.current).toBe(false);
|
||||
|
||||
unmount();
|
||||
|
||||
expect(workspace.offref).toHaveBeenCalledWith(ref);
|
||||
});
|
||||
});
|
||||
});
|
||||
19
src/agentMode/ui/useRelevantNotesPaneOpen.ts
Normal file
19
src/agentMode/ui/useRelevantNotesPaneOpen.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { RELEVANT_NOTES_VIEWTYPE } from "@/constants";
|
||||
import type { App } from "obsidian";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
/** True while a dedicated Relevant Notes pane leaf is open; updates on layout-change. */
|
||||
export function useRelevantNotesPaneOpen(app: App): boolean {
|
||||
const [open, setOpen] = useState(
|
||||
() => app.workspace.getLeavesOfType(RELEVANT_NOTES_VIEWTYPE).length > 0
|
||||
);
|
||||
useEffect(() => {
|
||||
// Re-sync on mount in case the workspace changed between the lazy-init read
|
||||
// and this effect running (e.g. a layout restored in the same tick).
|
||||
const update = () => setOpen(app.workspace.getLeavesOfType(RELEVANT_NOTES_VIEWTYPE).length > 0);
|
||||
update();
|
||||
const ref = app.workspace.on("layout-change", update);
|
||||
return () => app.workspace.offref(ref);
|
||||
}, [app]);
|
||||
return open;
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import { CHAT_VIEWTYPE } from "@/constants";
|
|||
import { ChatViewEventTarget, EventTargetContext } from "@/context";
|
||||
import CopilotPlugin from "@/main";
|
||||
import { FileParserManager } from "@/tools/FileParserManager";
|
||||
import { registerActiveLeafChangeBridge } from "@/utils/registerActiveLeafChangeBridge";
|
||||
import { mountPluginViewRoot, type PluginViewRootHandle } from "@/utils/react/mountPluginViewRoot";
|
||||
import * as Tooltip from "@radix-ui/react-tooltip";
|
||||
import { ItemView, Platform, WorkspaceLeaf } from "obsidian";
|
||||
|
|
@ -56,6 +57,8 @@ export default class CopilotView extends ItemView {
|
|||
this.setupMobileKeyboardObserver();
|
||||
this.setupDrawerHideObserver();
|
||||
|
||||
registerActiveLeafChangeBridge(this, this.eventTarget);
|
||||
|
||||
// Reason: The view can move between containers (e.g. editor tab → drawer)
|
||||
// without onOpen firing again. Re-bind the drawer observer on layout changes
|
||||
// so it always watches the correct drawer element.
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import { RelevantNotes } from "@/components/chat-components/RelevantNotes";
|
||||
import { EVENT_NAMES, RELEVANT_NOTES_VIEWTYPE } from "@/constants";
|
||||
import { RELEVANT_NOTES_VIEWTYPE } from "@/constants";
|
||||
import { EventTargetContext } from "@/context";
|
||||
import CopilotPlugin from "@/main";
|
||||
import { createPluginRoot } from "@/utils/react/createPluginRoot";
|
||||
import { ItemView, MarkdownView, WorkspaceLeaf } from "obsidian";
|
||||
import { registerActiveLeafChangeBridge } from "@/utils/registerActiveLeafChangeBridge";
|
||||
import { ItemView, WorkspaceLeaf } from "obsidian";
|
||||
import * as React from "react";
|
||||
import { Root } from "react-dom/client";
|
||||
|
||||
|
|
@ -12,9 +13,9 @@ import { Root } from "react-dom/client";
|
|||
*
|
||||
* `RelevantNotes` reads the active file via `useActiveFile`, which seeds from
|
||||
* the current active file on mount and then updates on the `ACTIVE_LEAF_CHANGE`
|
||||
* event on this view's own `eventTarget`. The plugin's global handler dispatches
|
||||
* that event only to the legacy chat view, so this view feeds its own
|
||||
* `eventTarget` (mirroring that handler's condition) on subsequent leaf changes.
|
||||
* event on this view's own `eventTarget`. This view bridges that event itself
|
||||
* via `registerActiveLeafChangeBridge` since no other component feeds its
|
||||
* `eventTarget`.
|
||||
*/
|
||||
export default class RelevantNotesView extends ItemView {
|
||||
private root: Root | null = null;
|
||||
|
|
@ -49,13 +50,7 @@ export default class RelevantNotesView extends ItemView {
|
|||
this.root = createPluginRoot(this.containerEl.children[1], this.app);
|
||||
this.renderView();
|
||||
|
||||
this.registerEvent(
|
||||
this.app.workspace.on("active-leaf-change", (leaf) => {
|
||||
if (leaf?.view instanceof MarkdownView && leaf.view.file) {
|
||||
this.eventTarget.dispatchEvent(new CustomEvent(EVENT_NAMES.ACTIVE_LEAF_CHANGE));
|
||||
}
|
||||
})
|
||||
);
|
||||
registerActiveLeafChangeBridge(this, this.eventTarget);
|
||||
}
|
||||
|
||||
private renderView(): void {
|
||||
|
|
|
|||
114
src/components/chat-components/RelevantNotes.test.tsx
Normal file
114
src/components/chat-components/RelevantNotes.test.tsx
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
/* eslint-disable @eslint-react/hooks-extra/no-unnecessary-use-prefix -- Mock exports must preserve production hook names. */
|
||||
import { RelevantNotes } from "@/components/chat-components/RelevantNotes";
|
||||
import { useActiveFile } from "@/hooks/useActiveFile";
|
||||
import { findRelevantNotes } from "@/search/findRelevantNotes";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { TFile } from "obsidian";
|
||||
import React from "react";
|
||||
|
||||
const mockOpenFile = jest.fn().mockResolvedValue(undefined);
|
||||
const mockGetLeaf = jest.fn(() => ({ openFile: mockOpenFile }));
|
||||
const mockApp = {
|
||||
vault: {
|
||||
getAbstractFileByPath: jest.fn(),
|
||||
cachedRead: jest.fn().mockResolvedValue(""),
|
||||
},
|
||||
workspace: {
|
||||
getLeaf: mockGetLeaf,
|
||||
},
|
||||
};
|
||||
|
||||
jest.mock("@/aiParams", () => ({
|
||||
useIndexingProgress: () => [{ isActive: false, indexedCount: 0, totalFiles: 0 }],
|
||||
}));
|
||||
|
||||
jest.mock("@/components/modals/SemanticSearchToggleModal", () => ({
|
||||
SemanticSearchToggleModal: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("@/context", () => ({
|
||||
useApp: () => mockApp,
|
||||
}));
|
||||
|
||||
jest.mock("@/hooks/useActiveFile", () => ({
|
||||
useActiveFile: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("@/hooks/useNoteDrag", () => ({
|
||||
useNoteDrag: () => jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("@/miyo/miyoUtils", () => ({
|
||||
shouldUseMiyo: () => false,
|
||||
}));
|
||||
|
||||
jest.mock("@/search/findRelevantNotes", () => ({
|
||||
findRelevantNotes: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("@/search/indexSignal", () => ({
|
||||
onIndexChanged: () => jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("@/search/searchUtils", () => ({
|
||||
getMatchingPatterns: () => ({ inclusions: [], exclusions: [] }),
|
||||
shouldIndexFile: () => true,
|
||||
}));
|
||||
|
||||
jest.mock("@/search/vectorStoreManager", () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
getInstance: () => ({
|
||||
hasIndex: jest.fn().mockResolvedValue(true),
|
||||
indexVaultToVectorStore: jest.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("@/settings/model", () => ({
|
||||
getSettings: () => ({ enableSemanticSearchV3: true }),
|
||||
updateSetting: jest.fn(),
|
||||
useSettingsValue: () => ({ qaInclusions: "", qaExclusions: "" }),
|
||||
}));
|
||||
|
||||
const mockUseActiveFile = useActiveFile as jest.MockedFunction<typeof useActiveFile>;
|
||||
const mockFindRelevantNotes = findRelevantNotes as jest.MockedFunction<typeof findRelevantNotes>;
|
||||
|
||||
function makeMarkdownFile(path: string): TFile {
|
||||
const MockTFile = TFile as unknown as new (path: string) => TFile;
|
||||
return new MockTFile(path);
|
||||
}
|
||||
|
||||
describe("RelevantNotes", () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
const sourceFile = makeMarkdownFile("Source.md");
|
||||
const targetFile = makeMarkdownFile("Target.md");
|
||||
mockUseActiveFile.mockReturnValue(sourceFile);
|
||||
mockApp.vault.getAbstractFileByPath.mockImplementation((path: string) =>
|
||||
path === targetFile.path ? targetFile : sourceFile
|
||||
);
|
||||
mockFindRelevantNotes.mockResolvedValue([
|
||||
{
|
||||
note: { path: targetFile.path, title: targetFile.basename },
|
||||
metadata: {
|
||||
score: 0.8,
|
||||
similarityScore: 0.8,
|
||||
hasOutgoingLinks: false,
|
||||
hasBacklinks: false,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
describe("RelevantNotes()", () => {
|
||||
it("opens a result in a new leaf", async () => {
|
||||
render(<RelevantNotes onAddToChat={jest.fn()} />);
|
||||
|
||||
fireEvent.click(await screen.findByText("Target"));
|
||||
|
||||
expect(mockGetLeaf).toHaveBeenCalledWith(true);
|
||||
expect(mockOpenFile).toHaveBeenCalledWith(expect.objectContaining({ path: "Target.md" }));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -139,7 +139,7 @@ function RelevantNoteHoverCard({
|
|||
}: {
|
||||
note: RelevantNoteEntry;
|
||||
onAddToChat: () => void;
|
||||
onNavigateToNote: (openInNewLeaf: boolean) => void;
|
||||
onNavigateToNote: () => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const app = useApp();
|
||||
|
|
@ -242,7 +242,7 @@ function RelevantNoteHoverCard({
|
|||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={(e) => onNavigateToNote(e.metaKey || e.ctrlKey)}
|
||||
onClick={onNavigateToNote}
|
||||
className="tw-flex-1 tw-gap-1.5"
|
||||
>
|
||||
Open note
|
||||
|
|
@ -261,7 +261,7 @@ function RelevantNoteRow({
|
|||
}: {
|
||||
note: RelevantNoteEntry;
|
||||
onAddToChat: () => void;
|
||||
onNavigateToNote: (openInNewLeaf: boolean) => void;
|
||||
onNavigateToNote: () => void;
|
||||
}) {
|
||||
const app = useApp();
|
||||
const handleDragStart = useNoteDrag();
|
||||
|
|
@ -285,13 +285,12 @@ function RelevantNoteRow({
|
|||
}}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onNavigateToNote(e.metaKey || e.ctrlKey);
|
||||
onNavigateToNote();
|
||||
}}
|
||||
onAuxClick={(e) => {
|
||||
if (e.button === 1) {
|
||||
// Middle click opens in a new leaf
|
||||
e.preventDefault();
|
||||
onNavigateToNote(true);
|
||||
onNavigateToNote();
|
||||
}
|
||||
}}
|
||||
className="tw-min-w-0 tw-flex-1 tw-cursor-pointer tw-truncate tw-text-sm tw-font-medium tw-text-normal !tw-no-underline"
|
||||
|
|
@ -333,7 +332,7 @@ function RelevantNoteRow({
|
|||
className="tw-size-6 tw-p-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onNavigateToNote(e.metaKey || e.ctrlKey);
|
||||
onNavigateToNote();
|
||||
}}
|
||||
>
|
||||
<ArrowRight className="tw-size-4" />
|
||||
|
|
@ -399,15 +398,14 @@ function BuildOverlay({ indexedCount, totalFiles }: { indexedCount: number; tota
|
|||
);
|
||||
}
|
||||
|
||||
interface RelevantNotesProps {
|
||||
className?: string;
|
||||
/** Insert text (a `[[wikilink]]`) into the target chat input. */
|
||||
onAddToChat: (text: string) => void;
|
||||
}
|
||||
|
||||
export const RelevantNotes = memo(
|
||||
({
|
||||
className,
|
||||
onAddToChat,
|
||||
}: {
|
||||
className?: string;
|
||||
/** Insert text (a `[[wikilink]]`) into the target chat input. */
|
||||
onAddToChat: (text: string) => void;
|
||||
}) => {
|
||||
({ className, onAddToChat }: RelevantNotesProps): React.ReactElement => {
|
||||
const app = useApp();
|
||||
const [refresher, setRefresher] = useState(0);
|
||||
const relevantNotes = useRelevantNotes(refresher);
|
||||
|
|
@ -428,10 +426,10 @@ export const RelevantNotes = memo(
|
|||
});
|
||||
return !shouldIndexFile(app, activeFile, inclusions, exclusions);
|
||||
}, [app, activeFile, settings.qaInclusions, settings.qaExclusions]);
|
||||
const navigateToNote = (notePath: string, openInNewLeaf = false) => {
|
||||
const navigateToNote = (notePath: string) => {
|
||||
const file = app.vault.getAbstractFileByPath(notePath);
|
||||
if (file instanceof TFile) {
|
||||
const leaf = app.workspace.getLeaf(openInNewLeaf);
|
||||
const leaf = app.workspace.getLeaf(true);
|
||||
void leaf.openFile(file).catch((err) => logError("openFile failed", err));
|
||||
}
|
||||
};
|
||||
|
|
@ -470,7 +468,10 @@ export const RelevantNotes = memo(
|
|||
return (
|
||||
<div className={cn("tw-flex tw-min-h-full tw-w-full tw-flex-1 tw-flex-col", className)}>
|
||||
{isActiveFileExcluded && (
|
||||
<div className="tw-flex tw-flex-1 tw-flex-col tw-items-center tw-justify-center tw-px-6">
|
||||
<div
|
||||
data-relevant-notes-empty-state
|
||||
className="tw-flex tw-flex-1 tw-flex-col tw-items-center tw-justify-center tw-px-6"
|
||||
>
|
||||
<div className="tw-flex tw-w-full tw-max-w-xs tw-flex-col tw-items-center tw-gap-6 tw-text-center">
|
||||
<div className="tw-flex tw-size-16 tw-items-center tw-justify-center tw-rounded-xl tw-border tw-border-solid tw-border-border tw-bg-secondary">
|
||||
<EyeOff className="tw-size-7 tw-text-muted" />
|
||||
|
|
@ -489,7 +490,10 @@ export const RelevantNotes = memo(
|
|||
)}
|
||||
|
||||
{!isActiveFileExcluded && !hasIndex && (
|
||||
<div className="tw-flex tw-flex-1 tw-flex-col tw-items-center tw-justify-center tw-px-6">
|
||||
<div
|
||||
data-relevant-notes-empty-state
|
||||
className="tw-flex tw-flex-1 tw-flex-col tw-items-center tw-justify-center tw-px-6"
|
||||
>
|
||||
<div className="tw-flex tw-w-full tw-max-w-xs tw-flex-col tw-items-center tw-gap-6 tw-text-center">
|
||||
<div className="tw-flex tw-size-16 tw-items-center tw-justify-center tw-rounded-xl tw-border tw-border-solid tw-border-border tw-bg-secondary">
|
||||
<GitFork className="tw-size-7 tw-text-accent" />
|
||||
|
|
@ -536,9 +540,7 @@ export const RelevantNotes = memo(
|
|||
key={note.note.path}
|
||||
note={note}
|
||||
onAddToChat={() => addToChat(note.note.title)}
|
||||
onNavigateToNote={(openInNewLeaf: boolean) =>
|
||||
navigateToNote(note.note.path, openInNewLeaf)
|
||||
}
|
||||
onNavigateToNote={() => navigateToNote(note.note.path)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
16
src/main.ts
16
src/main.ts
|
|
@ -373,22 +373,6 @@ export default class CopilotPlugin extends Plugin {
|
|||
if (activeViewType === CHAT_VIEWTYPE || activeViewType === CHAT_AGENT_VIEWTYPE) {
|
||||
this.lastActiveChatViewType = activeViewType;
|
||||
}
|
||||
|
||||
if (leaf && leaf.view instanceof MarkdownView) {
|
||||
const file = leaf.view.file;
|
||||
if (file) {
|
||||
// Note: File tracking and real-time reindexing removed for simplicity
|
||||
// Semantic search indexes are rebuilt manually or on startup as needed
|
||||
const activeCopilotView = this.app.workspace
|
||||
.getLeavesOfType(CHAT_VIEWTYPE)
|
||||
.find((leaf) => leaf.view instanceof CopilotView)?.view as CopilotView;
|
||||
|
||||
if (activeCopilotView) {
|
||||
const event = new CustomEvent(EVENT_NAMES.ACTIVE_LEAF_CHANGE);
|
||||
activeCopilotView.eventTarget.dispatchEvent(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
|
|
|
|||
46
src/utils/registerActiveLeafChangeBridge.test.ts
Normal file
46
src/utils/registerActiveLeafChangeBridge.test.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { EVENT_NAMES } from "@/constants";
|
||||
import { registerActiveLeafChangeBridge } from "@/utils/registerActiveLeafChangeBridge";
|
||||
import type { ItemView, WorkspaceLeaf } from "obsidian";
|
||||
import { MarkdownView, TFile } from "obsidian";
|
||||
|
||||
jest.mock("obsidian", () => ({
|
||||
TFile: class TFile {},
|
||||
MarkdownView: class MarkdownView {
|
||||
file: unknown = null;
|
||||
},
|
||||
}));
|
||||
|
||||
describe("registerActiveLeafChangeBridge", () => {
|
||||
describe("registerActiveLeafChangeBridge()", () => {
|
||||
it("registers a lifecycle-owned listener that dispatches for Markdown leaves with files", () => {
|
||||
let activeLeafChange: ((leaf: WorkspaceLeaf | null) => void) | undefined;
|
||||
const eventRef = {};
|
||||
const view = {
|
||||
app: {
|
||||
workspace: {
|
||||
on: jest.fn((_name: string, callback: (leaf: WorkspaceLeaf | null) => void) => {
|
||||
activeLeafChange = callback;
|
||||
return eventRef;
|
||||
}),
|
||||
},
|
||||
},
|
||||
registerEvent: jest.fn(),
|
||||
} as unknown as ItemView;
|
||||
const eventTarget = new EventTarget();
|
||||
const onActiveLeafChange = jest.fn();
|
||||
eventTarget.addEventListener(EVENT_NAMES.ACTIVE_LEAF_CHANGE, onActiveLeafChange);
|
||||
const markdownView = new MarkdownView({} as WorkspaceLeaf);
|
||||
markdownView.file = new TFile();
|
||||
|
||||
registerActiveLeafChangeBridge(view, eventTarget);
|
||||
activeLeafChange?.({ view: markdownView } as unknown as WorkspaceLeaf);
|
||||
|
||||
expect(view.app.workspace.on).toHaveBeenCalledWith(
|
||||
"active-leaf-change",
|
||||
expect.any(Function)
|
||||
);
|
||||
expect(view.registerEvent).toHaveBeenCalledWith(eventRef);
|
||||
expect(onActiveLeafChange).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
23
src/utils/registerActiveLeafChangeBridge.ts
Normal file
23
src/utils/registerActiveLeafChangeBridge.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { EVENT_NAMES } from "@/constants";
|
||||
import { MarkdownView, type ItemView } from "obsidian";
|
||||
|
||||
/**
|
||||
* Bridge Obsidian's workspace `active-leaf-change` onto a view-local
|
||||
* `eventTarget` so React consumers (via `useActiveFile`) re-read the active
|
||||
* file when the user switches notes.
|
||||
*
|
||||
* Each chat-ish view self-owns this bridge rather than relying on a single
|
||||
* plugin-level dispatch: `useActiveFile` listens on the view's own
|
||||
* `eventTarget`, so a view that never feeds its target stays frozen at its
|
||||
* mount seed. Registering through the view's `registerEvent` ties the listener
|
||||
* to the view lifecycle (auto-unregistered on close).
|
||||
*/
|
||||
export function registerActiveLeafChangeBridge(view: ItemView, eventTarget: EventTarget): void {
|
||||
view.registerEvent(
|
||||
view.app.workspace.on("active-leaf-change", (leaf) => {
|
||||
if (leaf?.view instanceof MarkdownView && leaf.view.file) {
|
||||
eventTarget.dispatchEvent(new CustomEvent(EVENT_NAMES.ACTIVE_LEAF_CHANGE));
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue