mirror of
https://github.com/logancyang/obsidian-copilot.git
synced 2026-07-22 07:50:24 +00:00
feat: add recent usage sorting for chat history and project list. (#2077)
* feat: add recent usage sorting for chat history and project list. # Conflicts: # src/main.ts * fix: use in-memory recency for chat history modal sorting. * refactor: move sort strategy selectors to AdvancedSettings.
This commit is contained in:
parent
270663aa1b
commit
cd6e4fc5ae
15 changed files with 1187 additions and 59 deletions
|
|
@ -16,10 +16,11 @@ import { logError, logInfo, logWarn } from "@/logger";
|
|||
import CopilotPlugin from "@/main";
|
||||
import { Mention } from "@/mentions/Mention";
|
||||
import { getMatchingPatterns, shouldIndexFile } from "@/search/searchUtils";
|
||||
import { getSettings, subscribeToSettingsChange } from "@/settings/model";
|
||||
import { getSettings, subscribeToSettingsChange, updateSetting } from "@/settings/model";
|
||||
import { FileParserManager } from "@/tools/FileParserManager";
|
||||
import { err2String } from "@/utils";
|
||||
import { isRateLimitError } from "@/utils/rateLimitUtils";
|
||||
import { RecentUsageManager } from "@/utils/recentUsageManager";
|
||||
import { App, Notice, TFile } from "obsidian";
|
||||
import { BrevilabsClient } from "./brevilabsClient";
|
||||
import ChainManager from "./chainManager";
|
||||
|
|
@ -34,6 +35,7 @@ export default class ProjectManager {
|
|||
private readonly projectContextCache: ProjectContextCache;
|
||||
private fileParserManager: FileParserManager;
|
||||
private loadTracker: ProjectLoadTracker;
|
||||
private readonly projectUsageTimestampsManager = new RecentUsageManager<string>();
|
||||
|
||||
private constructor(app: App, plugin: CopilotPlugin) {
|
||||
this.app = app;
|
||||
|
|
@ -90,8 +92,8 @@ export default class ProjectManager {
|
|||
for (const nextProject of nextProjects) {
|
||||
const prevProject = prevProjects.find((p) => p.id === nextProject.id);
|
||||
if (prevProject) {
|
||||
// Check if project configuration has changed
|
||||
if (JSON.stringify(prevProject) !== JSON.stringify(nextProject)) {
|
||||
// Check if project configuration has changed (ignoring UsageTimestamps)
|
||||
if (this.hasMeaningfulProjectConfigChange(prevProject, nextProject)) {
|
||||
// Compare project configuration changes and selectively update cache
|
||||
await this.compareAndUpdateCache(prevProject, nextProject);
|
||||
|
||||
|
|
@ -109,6 +111,19 @@ export default class ProjectManager {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether a project configuration change should trigger cache reloads.
|
||||
* Ignores `UsageTimestamps` updates used for "recently used" sorting.
|
||||
*/
|
||||
private hasMeaningfulProjectConfigChange(
|
||||
prevProject: ProjectConfig,
|
||||
nextProject: ProjectConfig
|
||||
): boolean {
|
||||
const prevComparable = { ...prevProject, UsageTimestamps: 0 };
|
||||
const nextComparable = { ...nextProject, UsageTimestamps: 0 };
|
||||
return JSON.stringify(prevComparable) !== JSON.stringify(nextComparable);
|
||||
}
|
||||
|
||||
public static getInstance(app: App, plugin: CopilotPlugin): ProjectManager {
|
||||
if (!ProjectManager.instance) {
|
||||
ProjectManager.instance = new ProjectManager(app, plugin);
|
||||
|
|
@ -124,6 +139,49 @@ export default class ProjectManager {
|
|||
return this.currentProjectId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Touch the project's usage timestamp in settings with throttled persistence.
|
||||
* Memory is always updated immediately (for UI sorting), but settings writes are throttled.
|
||||
*/
|
||||
private touchProjectUsageTimestamps(project: ProjectConfig): void {
|
||||
// Always update memory for immediate UI feedback
|
||||
this.projectUsageTimestampsManager.touch(project.id);
|
||||
|
||||
// Check if we should persist to settings (throttled)
|
||||
const currentProjects = getSettings().projectList || [];
|
||||
const persistedProject = currentProjects.find((p) => p.id === project.id);
|
||||
if (!persistedProject) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timestampToPersist = this.projectUsageTimestampsManager.shouldPersist(
|
||||
project.id,
|
||||
persistedProject.UsageTimestamps
|
||||
);
|
||||
|
||||
if (timestampToPersist === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateSetting(
|
||||
"projectList",
|
||||
currentProjects.map((p) =>
|
||||
p.id === project.id ? { ...p, UsageTimestamps: timestampToPersist } : p
|
||||
)
|
||||
);
|
||||
|
||||
// Mark persistence successful for throttling purposes
|
||||
this.projectUsageTimestampsManager.markPersisted(project.id, timestampToPersist);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the project usage timestamps manager for use in sorting.
|
||||
* This allows UI components to use in-memory values for immediate feedback.
|
||||
*/
|
||||
public getProjectUsageTimestampsManager(): RecentUsageManager<string> {
|
||||
return this.projectUsageTimestampsManager;
|
||||
}
|
||||
|
||||
public async switchProject(project: ProjectConfig | null): Promise<void> {
|
||||
try {
|
||||
// Clear all project context loading states
|
||||
|
|
@ -168,6 +226,9 @@ export default class ProjectManager {
|
|||
// fresh chat view
|
||||
this.refreshChatView();
|
||||
|
||||
// Touch "recently used" timestamp only after a successful switch.
|
||||
this.touchProjectUsageTimestamps(project);
|
||||
|
||||
logInfo(`Switched to project: ${project.name}`);
|
||||
} catch (error) {
|
||||
logError(`Failed to switch project: ${error}`);
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import {
|
|||
getNotesFromTags,
|
||||
processVariableNameForNotePath,
|
||||
} from "@/utils";
|
||||
import { sortByStrategy } from "@/utils/recentUsageManager";
|
||||
import {
|
||||
NOTE_CONTEXT_PROMPT_TAG,
|
||||
SELECTED_TEXT_TAG,
|
||||
|
|
@ -145,25 +146,28 @@ export async function loadAllCustomCommands(): Promise<CustomCommand[]> {
|
|||
}
|
||||
|
||||
export function sortCommandsByOrder(commands: CustomCommand[]): CustomCommand[] {
|
||||
return [...commands].sort((a, b) => {
|
||||
if (a.order === b.order) {
|
||||
return a.title.localeCompare(b.title);
|
||||
}
|
||||
return a.order - b.order;
|
||||
return sortByStrategy(commands, "manual", {
|
||||
getName: (command) => command.title,
|
||||
getCreatedAtMs: () => 0,
|
||||
getLastUsedAtMs: () => 0,
|
||||
getManualOrder: (command) => command.order,
|
||||
});
|
||||
}
|
||||
|
||||
export function sortCommandsByRecency(commands: CustomCommand[]): CustomCommand[] {
|
||||
return [...commands].sort((a, b) => {
|
||||
if (a.lastUsedMs === b.lastUsedMs) {
|
||||
return a.title.localeCompare(b.title);
|
||||
}
|
||||
return b.lastUsedMs - a.lastUsedMs;
|
||||
return sortByStrategy(commands, "recent", {
|
||||
getName: (command) => command.title,
|
||||
getCreatedAtMs: () => 0,
|
||||
getLastUsedAtMs: (command) => command.lastUsedMs,
|
||||
});
|
||||
}
|
||||
|
||||
export function sortCommandsByAlphabetical(commands: CustomCommand[]): CustomCommand[] {
|
||||
return [...commands].sort((a, b) => a.title.localeCompare(b.title));
|
||||
return sortByStrategy(commands, "name", {
|
||||
getName: (command) => command.title,
|
||||
getCreatedAtMs: () => 0,
|
||||
getLastUsedAtMs: () => 0,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -847,6 +847,7 @@ const ChatInternal: React.FC<ChatProps & { chatInput: ReturnType<typeof useChatI
|
|||
projects={settings.projectList || []}
|
||||
defaultOpen={true}
|
||||
app={app}
|
||||
plugin={plugin}
|
||||
hasMessages={false}
|
||||
onProjectAdded={handleAddProject}
|
||||
onEditProject={handleEditProject}
|
||||
|
|
|
|||
|
|
@ -7,12 +7,15 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover
|
|||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { logError } from "@/logger";
|
||||
import { useSettingsValue } from "@/settings/model";
|
||||
import { sortByStrategy } from "@/utils/recentUsageManager";
|
||||
import { Platform } from "obsidian";
|
||||
|
||||
export interface ChatHistoryItem {
|
||||
id: string;
|
||||
title: string;
|
||||
createdAt: Date;
|
||||
lastAccessedAt: Date;
|
||||
}
|
||||
|
||||
interface ChatHistoryPopoverProps {
|
||||
|
|
@ -38,6 +41,7 @@ export function ChatHistoryPopover({
|
|||
const [open, setOpen] = useState(false);
|
||||
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
|
||||
const isMobile = Platform.isMobile;
|
||||
const settings = useSettingsValue();
|
||||
|
||||
const filteredHistory = useMemo(() => {
|
||||
if (!searchQuery.trim()) return chatHistory;
|
||||
|
|
@ -46,7 +50,29 @@ export function ChatHistoryPopover({
|
|||
);
|
||||
}, [chatHistory, searchQuery]);
|
||||
|
||||
const sortedHistory = useMemo(() => {
|
||||
return sortByStrategy(filteredHistory, settings.chatHistorySortStrategy, {
|
||||
getName: (chat) => chat.title,
|
||||
getCreatedAtMs: (chat) => chat.createdAt.getTime(),
|
||||
getLastUsedAtMs: (chat) => chat.lastAccessedAt.getTime(),
|
||||
});
|
||||
}, [filteredHistory, settings.chatHistorySortStrategy]);
|
||||
|
||||
const groupedHistory = useMemo(() => {
|
||||
const sortStrategy = settings.chatHistorySortStrategy;
|
||||
|
||||
// For name sorting, show a flat list without time-based grouping
|
||||
if (sortStrategy === "name") {
|
||||
return [
|
||||
{
|
||||
key: "All",
|
||||
label: "All",
|
||||
chats: sortedHistory,
|
||||
priority: 0,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const groups: Array<{
|
||||
key: string;
|
||||
label: string;
|
||||
|
|
@ -56,8 +82,10 @@ export function ChatHistoryPopover({
|
|||
const groupMap = new Map<string, ChatHistoryItem[]>();
|
||||
const now = new Date();
|
||||
|
||||
filteredHistory.forEach((chat) => {
|
||||
const diffTime = now.getTime() - chat.createdAt.getTime();
|
||||
sortedHistory.forEach((chat) => {
|
||||
// Use lastAccessedAt for "recent" strategy, createdAt for "created" strategy
|
||||
const referenceDate = sortStrategy === "recent" ? chat.lastAccessedAt : chat.createdAt;
|
||||
const diffTime = now.getTime() - referenceDate.getTime();
|
||||
const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24));
|
||||
|
||||
let groupKey: string;
|
||||
|
|
@ -95,7 +123,7 @@ export function ChatHistoryPopover({
|
|||
|
||||
// Sort by priority, ensuring Today is at the top.
|
||||
return groups.sort((a, b) => a.priority - b.priority);
|
||||
}, [filteredHistory]);
|
||||
}, [settings.chatHistorySortStrategy, sortedHistory]);
|
||||
|
||||
const handleStartEdit = (id: string, currentTitle: string) => {
|
||||
setEditingId(id);
|
||||
|
|
|
|||
|
|
@ -15,7 +15,8 @@ import { HelpTooltip } from "@/components/ui/help-tooltip";
|
|||
import { SearchBar } from "@/components/ui/SearchBar";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { logError } from "@/logger";
|
||||
import { updateSetting } from "@/settings/model";
|
||||
import { updateSetting, useSettingsValue } from "@/settings/model";
|
||||
import { RecentUsageManager, sortByStrategy } from "@/utils/recentUsageManager";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
|
|
@ -32,6 +33,32 @@ import React, { memo, useEffect, useMemo, useState } from "react";
|
|||
import { filterProjects } from "@/utils/projectUtils";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
|
||||
/**
|
||||
* Subscribe to a {@link RecentUsageManager} revision so in-memory touches can trigger
|
||||
* re-sorting even when the backing list reference stays unchanged (e.g. when persistence
|
||||
* is throttled).
|
||||
*/
|
||||
function useRecentUsageManagerRevision<Key extends string>(
|
||||
manager: RecentUsageManager<Key> | null | undefined
|
||||
): number {
|
||||
const [revision, setRevision] = useState(() => manager?.getRevision() ?? 0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!manager) {
|
||||
setRevision(0);
|
||||
return;
|
||||
}
|
||||
|
||||
setRevision(manager.getRevision());
|
||||
|
||||
return manager.subscribe(() => {
|
||||
setRevision(manager.getRevision());
|
||||
});
|
||||
}, [manager]);
|
||||
|
||||
return revision;
|
||||
}
|
||||
|
||||
function ProjectItem({
|
||||
project,
|
||||
loadContext,
|
||||
|
|
@ -126,6 +153,7 @@ export const ProjectList = memo(
|
|||
projects,
|
||||
defaultOpen = false,
|
||||
app,
|
||||
plugin,
|
||||
onProjectAdded,
|
||||
onEditProject,
|
||||
hasMessages = false,
|
||||
|
|
@ -137,6 +165,7 @@ export const ProjectList = memo(
|
|||
projects: ProjectConfig[];
|
||||
defaultOpen?: boolean;
|
||||
app: App;
|
||||
plugin?: any; // CopilotPlugin, optional for backwards compatibility
|
||||
onProjectAdded: (project: ProjectConfig) => void;
|
||||
onEditProject: (originP: ProjectConfig, updateP: ProjectConfig) => void;
|
||||
hasMessages?: boolean;
|
||||
|
|
@ -149,6 +178,14 @@ export const ProjectList = memo(
|
|||
const [selectedProject, setSelectedProject] = useState<ProjectConfig | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const chatInput = useChatInput();
|
||||
const settings = useSettingsValue();
|
||||
|
||||
// Get the project usage manager for subscription
|
||||
const projectUsageTimestampsManager =
|
||||
plugin?.projectManager?.getProjectUsageTimestampsManager?.() as
|
||||
| RecentUsageManager<string>
|
||||
| undefined;
|
||||
const projectUsageRevision = useRecentUsageManagerRevision(projectUsageTimestampsManager);
|
||||
|
||||
// Auto collapse when messages appear
|
||||
useEffect(() => {
|
||||
|
|
@ -157,10 +194,38 @@ export const ProjectList = memo(
|
|||
}
|
||||
}, [hasMessages]);
|
||||
|
||||
// Sort projects based on sort strategy
|
||||
// Note: projectUsageRevision triggers re-sort when in-memory timestamps change,
|
||||
// even though it's not directly referenced in the callback
|
||||
const sortedProjects = useMemo(
|
||||
() =>
|
||||
sortByStrategy(projects, settings.projectListSortStrategy, {
|
||||
getName: (project) => project.name,
|
||||
getCreatedAtMs: (project) => project.created,
|
||||
getLastUsedAtMs: (project) => {
|
||||
// Use effective last used time (prefers in-memory value for immediate UI updates)
|
||||
if (projectUsageTimestampsManager) {
|
||||
return projectUsageTimestampsManager.getEffectiveLastUsedAt(
|
||||
project.id,
|
||||
project.UsageTimestamps
|
||||
);
|
||||
}
|
||||
return project.UsageTimestamps;
|
||||
},
|
||||
}),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- projectUsageRevision triggers re-sort when manager's in-memory state changes
|
||||
[
|
||||
projects,
|
||||
settings.projectListSortStrategy,
|
||||
projectUsageTimestampsManager,
|
||||
projectUsageRevision,
|
||||
]
|
||||
);
|
||||
|
||||
// Filter projects based on search query
|
||||
const filteredProjects = useMemo(() => {
|
||||
return filterProjects(projects, searchQuery);
|
||||
}, [projects, searchQuery]);
|
||||
return filterProjects(sortedProjects, searchQuery);
|
||||
}, [sortedProjects, searchQuery]);
|
||||
|
||||
const handleAddProject = () => {
|
||||
const modal = new AddProjectModal(app, async (project: ProjectConfig) => {
|
||||
|
|
@ -239,7 +304,7 @@ export const ProjectList = memo(
|
|||
<Select
|
||||
value={selectedProject.name}
|
||||
onValueChange={(value) => {
|
||||
const project = projects.find((p) => p.name === value);
|
||||
const project = sortedProjects.find((p) => p.name === value);
|
||||
if (project) {
|
||||
handleLoadContext(project);
|
||||
}
|
||||
|
|
@ -254,7 +319,7 @@ export const ProjectList = memo(
|
|||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent className="tw-truncate">
|
||||
{projects.map((project) => (
|
||||
{sortedProjects.map((project) => (
|
||||
<SelectItem
|
||||
key={project.name}
|
||||
value={project.name}
|
||||
|
|
@ -352,13 +417,11 @@ export const ProjectList = memo(
|
|||
{/* Search input box */}
|
||||
{projects.length > 0 && (
|
||||
<div className="tw-px-4 tw-pb-2 tw-pt-3">
|
||||
<div className="tw-relative">
|
||||
<SearchBar
|
||||
value={searchQuery}
|
||||
onChange={setSearchQuery}
|
||||
placeholder="Search projects..."
|
||||
/>
|
||||
</div>
|
||||
<SearchBar
|
||||
value={searchQuery}
|
||||
onChange={setSearchQuery}
|
||||
placeholder="Search projects..."
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="tw-max-h-[calc(3*5.7rem)] tw-overflow-y-auto tw-px-4 tw-pb-6 tw-pt-3">
|
||||
|
|
|
|||
|
|
@ -1,37 +1,57 @@
|
|||
import { getChatDisplayText } from "@/utils/chatHistoryUtils";
|
||||
import {
|
||||
extractChatDate,
|
||||
extractChatLastAccessedAtMs,
|
||||
extractChatTitle,
|
||||
getChatDisplayText,
|
||||
} from "@/utils/chatHistoryUtils";
|
||||
import { getSettings } from "@/settings/model";
|
||||
import { RecentUsageManager, sortByStrategy } from "@/utils/recentUsageManager";
|
||||
import { App, FuzzySuggestModal, TFile } from "obsidian";
|
||||
|
||||
export class LoadChatHistoryModal extends FuzzySuggestModal<TFile> {
|
||||
private onChooseFile: (file: TFile) => void;
|
||||
|
||||
/**
|
||||
* Create a modal for selecting a chat history file from the vault.
|
||||
*/
|
||||
constructor(
|
||||
app: App,
|
||||
private chatFiles: TFile[],
|
||||
private chatHistoryLastAccessedAtManager: RecentUsageManager<string>,
|
||||
onChooseFile: (file: TFile) => void
|
||||
) {
|
||||
super(app);
|
||||
this.onChooseFile = onChooseFile;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return chat history files sorted by the configured strategy.
|
||||
* Uses in-memory recency data for immediate UI feedback when available.
|
||||
*/
|
||||
getItems(): TFile[] {
|
||||
return this.chatFiles.sort((a, b) => {
|
||||
const getEpoch = (file: TFile) => {
|
||||
const frontmatter = this.app.metadataCache.getFileCache(file)?.frontmatter;
|
||||
return frontmatter && frontmatter.epoch ? frontmatter.epoch : file.stat.ctime;
|
||||
};
|
||||
|
||||
const epochA = getEpoch(a);
|
||||
const epochB = getEpoch(b);
|
||||
|
||||
// Sort in descending order (most recent first)
|
||||
return epochB - epochA;
|
||||
const sortStrategy = getSettings().chatHistorySortStrategy;
|
||||
return sortByStrategy(this.chatFiles, sortStrategy, {
|
||||
getName: (file) => extractChatTitle(file),
|
||||
getCreatedAtMs: (file) => extractChatDate(file).getTime(),
|
||||
getLastUsedAtMs: (file) => {
|
||||
// Reason: Use getEffectiveLastUsedAt to prefer in-memory value over persisted frontmatter.
|
||||
// This ensures the modal reflects recent access immediately, even within the throttle window.
|
||||
const persistedMs = extractChatLastAccessedAtMs(file);
|
||||
return this.chatHistoryLastAccessedAtManager.getEffectiveLastUsedAt(file.path, persistedMs);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the display label for a chat history file.
|
||||
*/
|
||||
getItemText(file: TFile): string {
|
||||
return getChatDisplayText(file);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle user selection.
|
||||
*/
|
||||
onChooseItem(file: TFile, _evt: MouseEvent | KeyboardEvent) {
|
||||
this.onChooseFile(file);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -820,6 +820,8 @@ export const DEFAULT_SETTINGS: CopilotSettings = {
|
|||
lexicalSearchRamLimit: 100, // Default 100 MB
|
||||
promptUsageTimestamps: {},
|
||||
promptSortStrategy: PromptSortStrategy.TIMESTAMP,
|
||||
chatHistorySortStrategy: "recent",
|
||||
projectListSortStrategy: "recent",
|
||||
defaultConversationNoteName: "{$topic}@{$date}_{$time}",
|
||||
/** @deprecated */
|
||||
inlineEditCommands: [],
|
||||
|
|
|
|||
|
|
@ -1465,4 +1465,129 @@ tags:
|
|||
expect(modelKeyLine).toBe('modelKey: "model\\\\with\\\\backslash|provider"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("lastAccessedAt preservation", () => {
|
||||
it("should preserve lastAccessedAt when updating existing file", async () => {
|
||||
const messages: ChatMessage[] = [
|
||||
{
|
||||
id: "1",
|
||||
message: "Hello",
|
||||
sender: USER_SENDER,
|
||||
timestamp: {
|
||||
epoch: 1695513480000,
|
||||
display: "2024/09/23 22:18:00",
|
||||
fileName: "2024_09_23_221800",
|
||||
},
|
||||
isVisible: true,
|
||||
},
|
||||
];
|
||||
|
||||
const existingFile = {
|
||||
path: "test-folder/Hello@20240923_221800.md",
|
||||
} as unknown as TFile;
|
||||
|
||||
const getFilesSpy = jest
|
||||
.spyOn(persistenceManager, "getChatHistoryFiles")
|
||||
.mockResolvedValue([existingFile]);
|
||||
|
||||
mockMessageRepo.getDisplayMessages.mockReturnValue(messages);
|
||||
mockApp.metadataCache.getFileCache.mockReturnValue({
|
||||
frontmatter: {
|
||||
epoch: 1695513480000,
|
||||
topic: "Existing Topic",
|
||||
lastAccessedAt: 1700000000000, // Existing lastAccessedAt
|
||||
},
|
||||
});
|
||||
|
||||
await persistenceManager.saveChat("gpt-4");
|
||||
|
||||
// Verify that modify was called with content containing lastAccessedAt
|
||||
expect(mockApp.vault.modify).toHaveBeenCalledWith(
|
||||
existingFile,
|
||||
expect.stringContaining("lastAccessedAt: 1700000000000")
|
||||
);
|
||||
|
||||
getFilesSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("should not include lastAccessedAt when it does not exist", async () => {
|
||||
const messages: ChatMessage[] = [
|
||||
{
|
||||
id: "1",
|
||||
message: "New chat",
|
||||
sender: USER_SENDER,
|
||||
timestamp: {
|
||||
epoch: 1695513480000,
|
||||
display: "2024/09/23 22:18:00",
|
||||
fileName: "2024_09_23_221800",
|
||||
},
|
||||
isVisible: true,
|
||||
},
|
||||
];
|
||||
|
||||
mockMessageRepo.getDisplayMessages.mockReturnValue(messages);
|
||||
mockApp.vault.getAbstractFileByPath.mockReturnValue(true);
|
||||
mockApp.vault.create.mockResolvedValue({
|
||||
path: "test-folder/New_chat@20240923_221800.md",
|
||||
} as TFile);
|
||||
|
||||
await persistenceManager.saveChat("gpt-4");
|
||||
|
||||
// Verify that create was called with content NOT containing lastAccessedAt
|
||||
const createCall = mockApp.vault.create.mock.calls[0];
|
||||
const content = createCall[1] as string;
|
||||
expect(content).not.toContain("lastAccessedAt:");
|
||||
});
|
||||
|
||||
it("should preserve lastAccessedAt during conflict resolution", async () => {
|
||||
const messages: ChatMessage[] = [
|
||||
{
|
||||
id: "1",
|
||||
message: "Conflict test",
|
||||
sender: USER_SENDER,
|
||||
timestamp: {
|
||||
epoch: 1695513480000,
|
||||
display: "2024/09/23 22:18:00",
|
||||
fileName: "2024_09_23_221800",
|
||||
},
|
||||
isVisible: true,
|
||||
},
|
||||
];
|
||||
|
||||
const existingFile = Object.create(TFile.prototype);
|
||||
Object.assign(existingFile, {
|
||||
path: "test-folder/Conflict_test@20240923_221800.md",
|
||||
basename: "Conflict_test@20240923_221800",
|
||||
});
|
||||
|
||||
const getFilesSpy = jest
|
||||
.spyOn(persistenceManager, "getChatHistoryFiles")
|
||||
.mockResolvedValue([]);
|
||||
|
||||
mockMessageRepo.getDisplayMessages.mockReturnValue(messages);
|
||||
mockApp.vault.create.mockRejectedValue(new Error("File already exists"));
|
||||
mockApp.vault.getAbstractFileByPath.mockImplementation((path: string) => {
|
||||
if (path === "test-folder/Conflict_test@20240923_221800.md") {
|
||||
return existingFile;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
mockApp.metadataCache.getFileCache.mockReturnValue({
|
||||
frontmatter: {
|
||||
topic: "Conflict Topic",
|
||||
lastAccessedAt: 1700000000000,
|
||||
},
|
||||
});
|
||||
|
||||
await persistenceManager.saveChat("gpt-4");
|
||||
|
||||
// Verify that modify was called with content containing lastAccessedAt
|
||||
expect(mockApp.vault.modify).toHaveBeenCalledWith(
|
||||
existingFile,
|
||||
expect.stringContaining("lastAccessedAt: 1700000000000")
|
||||
);
|
||||
|
||||
getFilesSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -60,9 +60,11 @@ export class ChatPersistenceManager {
|
|||
|
||||
// Check if a file with this epoch already exists
|
||||
const existingFile = await this.findFileByEpoch(firstMessageEpoch);
|
||||
let existingTopic = existingFile
|
||||
? this.app.metadataCache.getFileCache(existingFile)?.frontmatter?.topic
|
||||
const existingFrontmatter = existingFile
|
||||
? this.app.metadataCache.getFileCache(existingFile)?.frontmatter
|
||||
: undefined;
|
||||
let existingTopic = existingFrontmatter?.topic;
|
||||
const existingLastAccessedAt = existingFrontmatter?.lastAccessedAt;
|
||||
|
||||
const preferredFileName = existingFile
|
||||
? existingFile.path
|
||||
|
|
@ -72,7 +74,8 @@ export class ChatPersistenceManager {
|
|||
chatContent,
|
||||
firstMessageEpoch,
|
||||
modelKey,
|
||||
existingTopic
|
||||
existingTopic,
|
||||
existingLastAccessedAt
|
||||
);
|
||||
let targetFile: TFile | null = existingFile;
|
||||
|
||||
|
|
@ -90,11 +93,21 @@ export class ChatPersistenceManager {
|
|||
if (this.isFileAlreadyExistsError(error)) {
|
||||
const conflictFile = this.app.vault.getAbstractFileByPath(preferredFileName);
|
||||
if (conflictFile && conflictFile instanceof TFile) {
|
||||
// Update existingTopic to prevent unnecessary regeneration
|
||||
existingTopic =
|
||||
this.app.metadataCache.getFileCache(conflictFile)?.frontmatter?.topic ??
|
||||
existingTopic;
|
||||
await this.app.vault.modify(conflictFile, noteContent);
|
||||
// Read existing frontmatter to preserve lastAccessedAt and topic
|
||||
const conflictFrontmatter =
|
||||
this.app.metadataCache.getFileCache(conflictFile)?.frontmatter;
|
||||
existingTopic = conflictFrontmatter?.topic ?? existingTopic;
|
||||
const conflictLastAccessedAt = conflictFrontmatter?.lastAccessedAt;
|
||||
|
||||
// Regenerate content with preserved frontmatter values
|
||||
const updatedContent = this.generateNoteContent(
|
||||
chatContent,
|
||||
firstMessageEpoch,
|
||||
modelKey,
|
||||
existingTopic,
|
||||
conflictLastAccessedAt
|
||||
);
|
||||
await this.app.vault.modify(conflictFile, updatedContent);
|
||||
targetFile = conflictFile;
|
||||
new Notice("Existing chat note found - updating it now.");
|
||||
logInfo(
|
||||
|
|
@ -119,7 +132,21 @@ export class ChatPersistenceManager {
|
|||
if (this.isFileAlreadyExistsError(fallbackError)) {
|
||||
const conflictFile = this.app.vault.getAbstractFileByPath(fallbackName);
|
||||
if (conflictFile && conflictFile instanceof TFile) {
|
||||
await this.app.vault.modify(conflictFile, noteContent);
|
||||
// Read existing frontmatter to preserve lastAccessedAt
|
||||
const conflictFrontmatter =
|
||||
this.app.metadataCache.getFileCache(conflictFile)?.frontmatter;
|
||||
const conflictLastAccessedAt = conflictFrontmatter?.lastAccessedAt;
|
||||
const conflictTopic = conflictFrontmatter?.topic;
|
||||
|
||||
// Regenerate content with preserved frontmatter values
|
||||
const updatedContent = this.generateNoteContent(
|
||||
chatContent,
|
||||
firstMessageEpoch,
|
||||
modelKey,
|
||||
conflictTopic,
|
||||
conflictLastAccessedAt
|
||||
);
|
||||
await this.app.vault.modify(conflictFile, updatedContent);
|
||||
targetFile = conflictFile;
|
||||
new Notice("Existing chat note found - updating it now.");
|
||||
logInfo(
|
||||
|
|
@ -600,7 +627,8 @@ ${conversationSummary}`;
|
|||
chatContent: string,
|
||||
firstMessageEpoch: number,
|
||||
modelKey: string,
|
||||
topic?: string
|
||||
topic?: string,
|
||||
lastAccessedAt?: number
|
||||
): string {
|
||||
const settings = getSettings();
|
||||
const currentProject = getCurrentProject();
|
||||
|
|
@ -609,6 +637,7 @@ ${conversationSummary}`;
|
|||
epoch: ${firstMessageEpoch}
|
||||
modelKey: "${escapeYamlString(modelKey)}"
|
||||
${topic ? `topic: "${topic}"` : ""}
|
||||
${lastAccessedAt ? `lastAccessedAt: ${lastAccessedAt}` : ""}
|
||||
${currentProject ? `projectId: ${currentProject.id}` : ""}
|
||||
${currentProject ? `projectName: ${currentProject.name}` : ""}
|
||||
tags:
|
||||
|
|
|
|||
101
src/main.ts
101
src/main.ts
|
|
@ -19,7 +19,7 @@ import { ABORT_REASON, CHAT_VIEWTYPE, DEFAULT_OPEN_AREA, EVENT_NAMES } from "@/c
|
|||
import { ChatManager } from "@/core/ChatManager";
|
||||
import { MessageRepository } from "@/core/MessageRepository";
|
||||
import { encryptAllKeys } from "@/encryptionService";
|
||||
import { logInfo } from "@/logger";
|
||||
import { logInfo, logWarn } from "@/logger";
|
||||
import { logFileManager } from "@/logFileManager";
|
||||
import { UserMemoryManager } from "@/memory/UserMemoryManager";
|
||||
import { clearRecordedPromptPayload } from "@/LLMProviders/chainRunner/utils/promptPayloadRecorder";
|
||||
|
|
@ -54,7 +54,12 @@ import {
|
|||
WorkspaceLeaf,
|
||||
} from "obsidian";
|
||||
import { ChatHistoryItem } from "@/components/chat-components/ChatHistoryPopover";
|
||||
import { extractChatTitle, extractChatDate } from "@/utils/chatHistoryUtils";
|
||||
import {
|
||||
extractChatDate,
|
||||
extractChatLastAccessedAtMs,
|
||||
extractChatTitle,
|
||||
} from "@/utils/chatHistoryUtils";
|
||||
import { RecentUsageManager } from "@/utils/recentUsageManager";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
// Removed unused FileTrackingState interface
|
||||
|
|
@ -73,6 +78,7 @@ export default class CopilotPlugin extends Plugin {
|
|||
private selectionDebounceTimer?: number;
|
||||
private selectionChangeHandler?: () => void;
|
||||
private webSelectionTracker?: WebSelectionTracker;
|
||||
private readonly chatHistoryLastAccessedAtManager = new RecentUsageManager<string>();
|
||||
|
||||
async onload(): Promise<void> {
|
||||
await this.loadSettings();
|
||||
|
|
@ -550,7 +556,12 @@ export default class CopilotPlugin extends Plugin {
|
|||
new Notice("No chat history found.");
|
||||
return;
|
||||
}
|
||||
new LoadChatHistoryModal(this.app, chatFiles, this.loadChatHistory.bind(this)).open();
|
||||
new LoadChatHistoryModal(
|
||||
this.app,
|
||||
chatFiles,
|
||||
this.chatHistoryLastAccessedAtManager,
|
||||
this.loadChatHistory.bind(this)
|
||||
).open();
|
||||
}
|
||||
|
||||
async getChatHistoryFiles(): Promise<TFile[]> {
|
||||
|
|
@ -582,11 +593,82 @@ export default class CopilotPlugin extends Plugin {
|
|||
|
||||
async getChatHistoryItems(): Promise<ChatHistoryItem[]> {
|
||||
const files = await this.getChatHistoryFiles();
|
||||
return files.map((file) => ({
|
||||
id: file.path,
|
||||
title: extractChatTitle(file),
|
||||
createdAt: extractChatDate(file),
|
||||
}));
|
||||
return files.map((file) => {
|
||||
const createdAt = extractChatDate(file);
|
||||
const persistedLastAccessedAtMs = extractChatLastAccessedAtMs(file);
|
||||
|
||||
// Use effective last used time (prefers in-memory value for immediate UI updates)
|
||||
const effectiveLastAccessedAtMs = this.chatHistoryLastAccessedAtManager.getEffectiveLastUsedAt(
|
||||
file.path,
|
||||
persistedLastAccessedAtMs ?? createdAt.getTime()
|
||||
);
|
||||
const lastAccessedAt = new Date(effectiveLastAccessedAtMs);
|
||||
|
||||
return {
|
||||
id: file.path,
|
||||
title: extractChatTitle(file),
|
||||
createdAt,
|
||||
lastAccessedAt,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Record that a chat history file was accessed by updating its `lastAccessedAt`
|
||||
* YAML frontmatter field (epoch ms), with in-memory tracking and throttled persistence.
|
||||
*
|
||||
* Memory is always updated immediately (for UI sorting), but disk writes are throttled and monotonic.
|
||||
*/
|
||||
private async touchChatHistoryLastAccessedAt(file: TFile): Promise<void> {
|
||||
try {
|
||||
// Always update memory for immediate UI feedback
|
||||
this.chatHistoryLastAccessedAtManager.touch(file.path);
|
||||
|
||||
// Check if we should persist to disk (throttled)
|
||||
const persistedLastAccessedAtMs = extractChatLastAccessedAtMs(file);
|
||||
const timestampToPersist = this.chatHistoryLastAccessedAtManager.shouldPersist(
|
||||
file.path,
|
||||
persistedLastAccessedAtMs
|
||||
);
|
||||
|
||||
if (timestampToPersist === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.app.fileManager?.processFrontMatter) {
|
||||
return;
|
||||
}
|
||||
|
||||
let persistedAtMs = timestampToPersist;
|
||||
|
||||
await this.app.fileManager.processFrontMatter(file, (frontmatter) => {
|
||||
// Monotonic protection: ensure we never write an older timestamp
|
||||
const existingValue = Number(frontmatter.lastAccessedAt);
|
||||
const existingAtMs =
|
||||
Number.isFinite(existingValue) && existingValue > 0 ? existingValue : 0;
|
||||
|
||||
persistedAtMs = Math.max(existingAtMs, timestampToPersist);
|
||||
|
||||
if (existingAtMs === persistedAtMs) {
|
||||
return;
|
||||
}
|
||||
|
||||
frontmatter.lastAccessedAt = persistedAtMs;
|
||||
});
|
||||
|
||||
// Mark persistence successful for throttling purposes
|
||||
this.chatHistoryLastAccessedAtManager.markPersisted(file.path, persistedAtMs);
|
||||
} catch (error) {
|
||||
logWarn(`[CopilotPlugin] Failed to update chat lastAccessedAt for ${file.path}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the chat history last accessed at manager for use in sorting.
|
||||
* This allows UI components to use in-memory values for immediate feedback.
|
||||
*/
|
||||
getChatHistoryLastAccessedAtManager(): RecentUsageManager<string> {
|
||||
return this.chatHistoryLastAccessedAtManager;
|
||||
}
|
||||
|
||||
async loadChatHistory(file: TFile) {
|
||||
|
|
@ -603,6 +685,9 @@ export default class CopilotPlugin extends Plugin {
|
|||
// Load messages using ChatUIState (which now uses ChatPersistenceManager internally)
|
||||
await this.chatUIState.loadChatHistory(file);
|
||||
|
||||
// Touch "lastAccessedAt" timestamp (throttled to avoid frequent writes)
|
||||
void this.touchChatHistoryLastAccessedAt(file);
|
||||
|
||||
// Update the view
|
||||
const copilotView = (existingView || this.app.workspace.getLeavesOfType(CHAT_VIEWTYPE)[0])
|
||||
?.view as CopilotView;
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { v4 as uuidv4 } from "uuid";
|
|||
import { UserMemoryManager } from "@/memory/UserMemoryManager";
|
||||
|
||||
import { type ChainType } from "@/chainFactory";
|
||||
import { type SortStrategy, isSortStrategy } from "@/utils/recentUsageManager";
|
||||
import {
|
||||
BUILTIN_CHAT_MODELS,
|
||||
BUILTIN_EMBEDDING_MODELS,
|
||||
|
|
@ -104,6 +105,8 @@ export interface CopilotSettings {
|
|||
activeEmbeddingModels: Array<CustomModel>;
|
||||
promptUsageTimestamps: Record<string, number>;
|
||||
promptSortStrategy: string;
|
||||
chatHistorySortStrategy: SortStrategy;
|
||||
projectListSortStrategy: SortStrategy;
|
||||
embeddingRequestsPerMin: number;
|
||||
embeddingBatchSize: number;
|
||||
defaultOpenArea: DEFAULT_OPEN_AREA;
|
||||
|
|
@ -434,6 +437,22 @@ export function sanitizeSettings(settings: CopilotSettings): CopilotSettings {
|
|||
sanitizedSettings.customPromptsFolder =
|
||||
promptsFolder.length > 0 ? promptsFolder : DEFAULT_SETTINGS.customPromptsFolder;
|
||||
|
||||
// Ensure chatHistorySortStrategy has a valid value (exclude "manual" which is only for custom commands)
|
||||
if (
|
||||
!isSortStrategy(sanitizedSettings.chatHistorySortStrategy) ||
|
||||
sanitizedSettings.chatHistorySortStrategy === "manual"
|
||||
) {
|
||||
sanitizedSettings.chatHistorySortStrategy = DEFAULT_SETTINGS.chatHistorySortStrategy;
|
||||
}
|
||||
|
||||
// Ensure projectListSortStrategy has a valid value (exclude "manual" which is only for custom commands)
|
||||
if (
|
||||
!isSortStrategy(sanitizedSettings.projectListSortStrategy) ||
|
||||
sanitizedSettings.projectListSortStrategy === "manual"
|
||||
) {
|
||||
sanitizedSettings.projectListSortStrategy = DEFAULT_SETTINGS.projectListSortStrategy;
|
||||
}
|
||||
|
||||
sanitizedSettings.qaExclusions = sanitizeQaExclusions(settingsToSanitize.qaExclusions);
|
||||
|
||||
return sanitizedSettings;
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { SettingItem } from "@/components/ui/setting-item";
|
|||
import { logFileManager } from "@/logFileManager";
|
||||
import { flushRecordedPromptPayloadToLog } from "@/LLMProviders/chainRunner/utils/promptPayloadRecorder";
|
||||
import { updateSetting, useSettingsValue } from "@/settings/model";
|
||||
import { isSortStrategy } from "@/utils/recentUsageManager";
|
||||
import React from "react";
|
||||
|
||||
export const AdvancedSettings: React.FC = () => {
|
||||
|
|
@ -10,6 +11,50 @@ export const AdvancedSettings: React.FC = () => {
|
|||
|
||||
return (
|
||||
<div className="tw-space-y-4">
|
||||
{/* Sorting Settings Section */}
|
||||
<section>
|
||||
<div className="tw-mb-4 tw-flex tw-flex-col tw-gap-2">
|
||||
<div className="tw-text-xl tw-font-bold">Sorting</div>
|
||||
<div className="tw-text-sm tw-text-muted">
|
||||
Configure default sort order for various lists.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SettingItem
|
||||
type="select"
|
||||
title="Chat History Sort Strategy"
|
||||
description="Sort order for the chat history list"
|
||||
value={settings.chatHistorySortStrategy}
|
||||
onChange={(value) => {
|
||||
if (isSortStrategy(value)) {
|
||||
updateSetting("chatHistorySortStrategy", value);
|
||||
}
|
||||
}}
|
||||
options={[
|
||||
{ label: "Recency", value: "recent" },
|
||||
{ label: "Created", value: "created" },
|
||||
{ label: "Alphabetical", value: "name" },
|
||||
]}
|
||||
/>
|
||||
|
||||
<SettingItem
|
||||
type="select"
|
||||
title="Project List Sort Strategy"
|
||||
description="Sort order for the project list"
|
||||
value={settings.projectListSortStrategy}
|
||||
onChange={(value) => {
|
||||
if (isSortStrategy(value)) {
|
||||
updateSetting("projectListSortStrategy", value);
|
||||
}
|
||||
}}
|
||||
options={[
|
||||
{ label: "Recency", value: "recent" },
|
||||
{ label: "Created", value: "created" },
|
||||
{ label: "Alphabetical", value: "name" },
|
||||
]}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Privacy Settings Section */}
|
||||
<section>
|
||||
<SettingItem
|
||||
|
|
|
|||
|
|
@ -44,6 +44,42 @@ export function extractChatDate(file: TFile): Date {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract chat last accessed time (epoch ms) from a file.
|
||||
* Uses frontmatter.lastAccessedAt if available, returns null otherwise.
|
||||
*/
|
||||
export function extractChatLastAccessedAtMs(file: TFile): number | null {
|
||||
const frontmatter = app.metadataCache.getFileCache(file)?.frontmatter;
|
||||
const rawValue = frontmatter?.lastAccessedAt;
|
||||
|
||||
if (typeof rawValue === "number" && Number.isFinite(rawValue) && rawValue > 0) {
|
||||
return rawValue;
|
||||
}
|
||||
|
||||
if (typeof rawValue === "string") {
|
||||
const numeric = Number(rawValue);
|
||||
if (Number.isFinite(numeric) && numeric > 0) {
|
||||
return numeric;
|
||||
}
|
||||
|
||||
const parsedDate = Date.parse(rawValue);
|
||||
if (Number.isFinite(parsedDate)) {
|
||||
return parsedDate;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract chat last accessed date from a file.
|
||||
* Uses extractChatLastAccessedAtMs and returns a Date when available, null otherwise.
|
||||
*/
|
||||
export function extractChatLastAccessedAt(file: TFile): Date | null {
|
||||
const lastAccessedAtMs = extractChatLastAccessedAtMs(file);
|
||||
return lastAccessedAtMs ? new Date(lastAccessedAtMs) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get formatted display text for a chat file (title + formatted date).
|
||||
* Used in chat history modals and similar UI components.
|
||||
|
|
|
|||
274
src/utils/recentUsageManager.test.ts
Normal file
274
src/utils/recentUsageManager.test.ts
Normal file
|
|
@ -0,0 +1,274 @@
|
|||
import { isSortStrategy, RecentUsageManager, sortByStrategy } from "./recentUsageManager";
|
||||
|
||||
describe("recentUsageManager", () => {
|
||||
describe("isSortStrategy", () => {
|
||||
it("returns true for valid strategies", () => {
|
||||
expect(isSortStrategy("recent")).toBe(true);
|
||||
expect(isSortStrategy("created")).toBe(true);
|
||||
expect(isSortStrategy("name")).toBe(true);
|
||||
expect(isSortStrategy("manual")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for invalid strategies", () => {
|
||||
expect(isSortStrategy("invalid")).toBe(false);
|
||||
expect(isSortStrategy("")).toBe(false);
|
||||
expect(isSortStrategy(null)).toBe(false);
|
||||
expect(isSortStrategy(undefined)).toBe(false);
|
||||
expect(isSortStrategy(123)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sortByStrategy", () => {
|
||||
interface TestItem {
|
||||
name: string;
|
||||
createdAt: number;
|
||||
lastUsedAt: number | null;
|
||||
order?: number;
|
||||
}
|
||||
|
||||
const getters = {
|
||||
getName: (item: TestItem) => item.name,
|
||||
getCreatedAtMs: (item: TestItem) => item.createdAt,
|
||||
getLastUsedAtMs: (item: TestItem) => item.lastUsedAt,
|
||||
getManualOrder: (item: TestItem) => item.order ?? 0,
|
||||
};
|
||||
|
||||
const items: TestItem[] = [
|
||||
{ name: "Beta", createdAt: 2000, lastUsedAt: 1000, order: 2 },
|
||||
{ name: "Alpha", createdAt: 1000, lastUsedAt: 3000, order: 1 },
|
||||
{ name: "Gamma", createdAt: 3000, lastUsedAt: 2000, order: 3 },
|
||||
];
|
||||
|
||||
it("sorts by recent (descending by lastUsedAt)", () => {
|
||||
const sorted = sortByStrategy(items, "recent", getters);
|
||||
expect(sorted.map((i) => i.name)).toEqual(["Alpha", "Gamma", "Beta"]);
|
||||
});
|
||||
|
||||
it("sorts by created (descending by createdAt)", () => {
|
||||
const sorted = sortByStrategy(items, "created", getters);
|
||||
expect(sorted.map((i) => i.name)).toEqual(["Gamma", "Beta", "Alpha"]);
|
||||
});
|
||||
|
||||
it("sorts by name (ascending alphabetically)", () => {
|
||||
const sorted = sortByStrategy(items, "name", getters);
|
||||
expect(sorted.map((i) => i.name)).toEqual(["Alpha", "Beta", "Gamma"]);
|
||||
});
|
||||
|
||||
it("sorts by manual order (ascending)", () => {
|
||||
const sorted = sortByStrategy(items, "manual", getters);
|
||||
expect(sorted.map((i) => i.name)).toEqual(["Alpha", "Beta", "Gamma"]);
|
||||
});
|
||||
|
||||
it("falls back to createdAt when lastUsedAt is null for recent strategy", () => {
|
||||
const itemsWithNull: TestItem[] = [
|
||||
{ name: "A", createdAt: 1000, lastUsedAt: null },
|
||||
{ name: "B", createdAt: 3000, lastUsedAt: null },
|
||||
{ name: "C", createdAt: 2000, lastUsedAt: 5000 },
|
||||
];
|
||||
const sorted = sortByStrategy(itemsWithNull, "recent", getters);
|
||||
// C has lastUsedAt=5000, B falls back to createdAt=3000, A falls back to createdAt=1000
|
||||
expect(sorted.map((i) => i.name)).toEqual(["C", "B", "A"]);
|
||||
});
|
||||
|
||||
it("uses name as tie-breaker when timestamps are equal", () => {
|
||||
const itemsWithSameTime: TestItem[] = [
|
||||
{ name: "Zebra", createdAt: 1000, lastUsedAt: 2000 },
|
||||
{ name: "Apple", createdAt: 1000, lastUsedAt: 2000 },
|
||||
{ name: "Mango", createdAt: 1000, lastUsedAt: 2000 },
|
||||
];
|
||||
const sorted = sortByStrategy(itemsWithSameTime, "recent", getters);
|
||||
expect(sorted.map((i) => i.name)).toEqual(["Apple", "Mango", "Zebra"]);
|
||||
});
|
||||
|
||||
it("falls back to name sort when manual strategy but no getManualOrder", () => {
|
||||
const gettersWithoutManual = {
|
||||
getName: (item: TestItem) => item.name,
|
||||
getCreatedAtMs: (item: TestItem) => item.createdAt,
|
||||
getLastUsedAtMs: (item: TestItem) => item.lastUsedAt,
|
||||
// No getManualOrder
|
||||
};
|
||||
const sorted = sortByStrategy(items, "manual", gettersWithoutManual);
|
||||
expect(sorted.map((i) => i.name)).toEqual(["Alpha", "Beta", "Gamma"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("RecentUsageManager", () => {
|
||||
describe("touch and shouldPersist", () => {
|
||||
it("touch always returns current timestamp and updates memory", () => {
|
||||
let currentTime = 1000;
|
||||
const manager = new RecentUsageManager({
|
||||
nowMs: () => currentTime,
|
||||
});
|
||||
|
||||
const ts1 = manager.touch("key1");
|
||||
expect(ts1).toBe(1000);
|
||||
expect(manager.getLastTouchedAt("key1")).toBe(1000);
|
||||
|
||||
currentTime = 2000; // eslint-disable-line prefer-const
|
||||
const ts2 = manager.touch("key1");
|
||||
expect(ts2).toBe(2000);
|
||||
expect(manager.getLastTouchedAt("key1")).toBe(2000);
|
||||
});
|
||||
|
||||
it("shouldPersist returns timestamp on first touch", () => {
|
||||
const currentTime = 1000;
|
||||
const manager = new RecentUsageManager({
|
||||
nowMs: () => currentTime,
|
||||
minIntervalMs: 30000,
|
||||
});
|
||||
|
||||
manager.touch("key1");
|
||||
const result = manager.shouldPersist("key1", null);
|
||||
expect(result).toBe(1000);
|
||||
});
|
||||
|
||||
it("shouldPersist returns null when throttled (within minIntervalMs)", () => {
|
||||
let currentTime = 1000;
|
||||
const manager = new RecentUsageManager({
|
||||
nowMs: () => currentTime,
|
||||
minIntervalMs: 30000,
|
||||
});
|
||||
|
||||
manager.touch("key1");
|
||||
const firstPersist = manager.shouldPersist("key1", null);
|
||||
expect(firstPersist).toBe(1000);
|
||||
manager.markPersisted("key1", firstPersist!); // Mark first persistence
|
||||
|
||||
currentTime = 15000; // 15 seconds later
|
||||
manager.touch("key1");
|
||||
const result = manager.shouldPersist("key1", null);
|
||||
expect(result).toBeNull(); // Throttled
|
||||
});
|
||||
|
||||
it("shouldPersist returns timestamp after throttle period", () => {
|
||||
let currentTime = 1000;
|
||||
const manager = new RecentUsageManager({
|
||||
nowMs: () => currentTime,
|
||||
minIntervalMs: 30000,
|
||||
});
|
||||
|
||||
manager.touch("key1");
|
||||
const firstPersist = manager.shouldPersist("key1", null);
|
||||
expect(firstPersist).toBe(1000);
|
||||
manager.markPersisted("key1", firstPersist!); // Mark first persistence at 1000
|
||||
|
||||
currentTime = 35000; // 34 seconds later (past throttle)
|
||||
manager.touch("key1");
|
||||
const result = manager.shouldPersist("key1", null);
|
||||
expect(result).toBe(35000);
|
||||
});
|
||||
|
||||
it("shouldPersist considers persisted value for throttling", () => {
|
||||
const currentTime = 50000;
|
||||
const manager = new RecentUsageManager({
|
||||
nowMs: () => currentTime,
|
||||
minIntervalMs: 30000,
|
||||
});
|
||||
|
||||
// Simulate: persisted value is 40000, current time is 50000
|
||||
// 50000 - 40000 = 10000 < 30000, so should be throttled
|
||||
manager.touch("key1");
|
||||
const result = manager.shouldPersist("key1", 40000);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("revision and subscribe", () => {
|
||||
it("increments revision and notifies subscribers on touch", () => {
|
||||
const manager = new RecentUsageManager({ nowMs: () => 1000 });
|
||||
const listener = jest.fn();
|
||||
const unsubscribe = manager.subscribe(listener);
|
||||
|
||||
const initialRevision = manager.getRevision();
|
||||
manager.touch("key1");
|
||||
|
||||
expect(manager.getRevision()).toBe(initialRevision + 1);
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
|
||||
unsubscribe();
|
||||
manager.touch("key1");
|
||||
expect(listener).toHaveBeenCalledTimes(1); // Not called again after unsubscribe
|
||||
});
|
||||
|
||||
it("increments revision on clear", () => {
|
||||
const manager = new RecentUsageManager({ nowMs: () => 1000 });
|
||||
const listener = jest.fn();
|
||||
manager.subscribe(listener);
|
||||
|
||||
manager.touch("key1");
|
||||
const revisionAfterTouch = manager.getRevision();
|
||||
|
||||
manager.clear("key1");
|
||||
expect(manager.getRevision()).toBe(revisionAfterTouch + 1);
|
||||
expect(listener).toHaveBeenCalledTimes(2); // Once for touch, once for clear
|
||||
});
|
||||
|
||||
it("does not increment revision on shouldPersist or markPersisted", () => {
|
||||
const manager = new RecentUsageManager({ nowMs: () => 1000 });
|
||||
manager.touch("key1");
|
||||
const revisionAfterTouch = manager.getRevision();
|
||||
|
||||
manager.shouldPersist("key1", null);
|
||||
expect(manager.getRevision()).toBe(revisionAfterTouch);
|
||||
|
||||
manager.markPersisted("key1", 1000);
|
||||
expect(manager.getRevision()).toBe(revisionAfterTouch);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getEffectiveLastUsedAt", () => {
|
||||
it("returns memory value when it is more recent", () => {
|
||||
const currentTime = 5000;
|
||||
const manager = new RecentUsageManager({
|
||||
nowMs: () => currentTime,
|
||||
});
|
||||
|
||||
manager.touch("key1"); // Memory = 5000
|
||||
const effective = manager.getEffectiveLastUsedAt("key1", 3000); // Persisted = 3000
|
||||
expect(effective).toBe(5000);
|
||||
});
|
||||
|
||||
it("returns persisted value when memory is not set", () => {
|
||||
const manager = new RecentUsageManager();
|
||||
const effective = manager.getEffectiveLastUsedAt("key1", 3000);
|
||||
expect(effective).toBe(3000);
|
||||
});
|
||||
|
||||
it("returns 0 when both are null/undefined", () => {
|
||||
const manager = new RecentUsageManager();
|
||||
const effective = manager.getEffectiveLastUsedAt("key1", null);
|
||||
expect(effective).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("clear", () => {
|
||||
it("clears specific key", () => {
|
||||
const currentTime = 1000;
|
||||
const manager = new RecentUsageManager({
|
||||
nowMs: () => currentTime,
|
||||
});
|
||||
|
||||
manager.touch("key1");
|
||||
manager.touch("key2");
|
||||
manager.clear("key1");
|
||||
|
||||
expect(manager.getLastTouchedAt("key1")).toBeNull();
|
||||
expect(manager.getLastTouchedAt("key2")).toBe(1000);
|
||||
});
|
||||
|
||||
it("clears all keys when no argument", () => {
|
||||
const currentTime = 1000;
|
||||
const manager = new RecentUsageManager({
|
||||
nowMs: () => currentTime,
|
||||
});
|
||||
|
||||
manager.touch("key1");
|
||||
manager.touch("key2");
|
||||
manager.clear();
|
||||
|
||||
expect(manager.getLastTouchedAt("key1")).toBeNull();
|
||||
expect(manager.getLastTouchedAt("key2")).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
336
src/utils/recentUsageManager.ts
Normal file
336
src/utils/recentUsageManager.ts
Normal file
|
|
@ -0,0 +1,336 @@
|
|||
export const SORT_STRATEGIES = ["recent", "created", "name", "manual"] as const;
|
||||
|
||||
/**
|
||||
* Supported sort strategies for lists that can be ordered by "recent usage".
|
||||
*/
|
||||
export type SortStrategy = (typeof SORT_STRATEGIES)[number];
|
||||
|
||||
export interface RecentUsageSortGetters<T> {
|
||||
/**
|
||||
* Human-readable name used for alphabetical sorting and tie-breaking.
|
||||
*/
|
||||
getName: (item: T) => string;
|
||||
|
||||
/**
|
||||
* Creation time in epoch milliseconds.
|
||||
*/
|
||||
getCreatedAtMs: (item: T) => number;
|
||||
|
||||
/**
|
||||
* Last-used/accessed time in epoch milliseconds.
|
||||
* When missing, callers should treat it as unknown.
|
||||
*/
|
||||
getLastUsedAtMs: (item: T) => number | null | undefined;
|
||||
|
||||
/**
|
||||
* Manual sort order (lower comes first). Only used when strategy is `manual`.
|
||||
*/
|
||||
getManualOrder?: (item: T) => number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard for {@link SortStrategy}.
|
||||
*/
|
||||
export function isSortStrategy(value: unknown): value is SortStrategy {
|
||||
return typeof value === "string" && (SORT_STRATEGIES as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a name for consistent sorting.
|
||||
*/
|
||||
function normalizeName(value: unknown): string {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize an epoch timestamp (ms) from unknown values.
|
||||
*/
|
||||
function normalizeTimestampMs(value: unknown): number | null {
|
||||
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
const numeric = Number(value);
|
||||
if (Number.isFinite(numeric) && numeric > 0) {
|
||||
return numeric;
|
||||
}
|
||||
|
||||
const parsedDate = Date.parse(value);
|
||||
if (Number.isFinite(parsedDate)) {
|
||||
return parsedDate;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a comparator for sorting items by {@link SortStrategy}.
|
||||
*
|
||||
* - `recent`: descending by `lastUsedAt` (falls back to `createdAt` when missing)
|
||||
* - `created`: descending by `createdAt`
|
||||
* - `name`: ascending by `name` (using default locale comparison via Intl.Collator)
|
||||
* - `manual`: ascending by `manualOrder` (falls back to `name` when unavailable)
|
||||
*/
|
||||
export function createSortComparator<T>(
|
||||
strategy: SortStrategy,
|
||||
getters: RecentUsageSortGetters<T>
|
||||
): (a: T, b: T) => number {
|
||||
// Use default Intl.Collator to match localeCompare behavior for backwards compatibility
|
||||
const collator = new Intl.Collator(undefined);
|
||||
|
||||
const getName = (item: T): string => normalizeName(getters.getName(item));
|
||||
|
||||
const getCreatedAtMs = (item: T): number => {
|
||||
const createdAtMs = getters.getCreatedAtMs(item);
|
||||
return Number.isFinite(createdAtMs) ? createdAtMs : 0;
|
||||
};
|
||||
|
||||
const getEffectiveLastUsedAtMs = (item: T): number => {
|
||||
const createdAtMs = getCreatedAtMs(item);
|
||||
const lastUsedAtMs = normalizeTimestampMs(getters.getLastUsedAtMs(item));
|
||||
return lastUsedAtMs ?? createdAtMs;
|
||||
};
|
||||
|
||||
const getManualOrder =
|
||||
typeof getters.getManualOrder === "function"
|
||||
? (item: T): number => {
|
||||
const order = getters.getManualOrder?.(item);
|
||||
return typeof order === "number" && Number.isFinite(order) ? order : 0;
|
||||
}
|
||||
: null;
|
||||
|
||||
// Fallback to name sorting if manual strategy requested but no getManualOrder provided
|
||||
const effectiveStrategy: SortStrategy =
|
||||
strategy === "manual" && !getManualOrder ? "name" : strategy;
|
||||
|
||||
return (a: T, b: T) => {
|
||||
const aName = getName(a);
|
||||
const bName = getName(b);
|
||||
|
||||
const aCreated = getCreatedAtMs(a);
|
||||
const bCreated = getCreatedAtMs(b);
|
||||
|
||||
const aRecent = getEffectiveLastUsedAtMs(a);
|
||||
const bRecent = getEffectiveLastUsedAtMs(b);
|
||||
|
||||
// Primary sort based on strategy
|
||||
if (effectiveStrategy === "manual") {
|
||||
const aOrder = getManualOrder!(a);
|
||||
const bOrder = getManualOrder!(b);
|
||||
if (aOrder !== bOrder) {
|
||||
return aOrder - bOrder;
|
||||
}
|
||||
} else if (effectiveStrategy === "name") {
|
||||
const nameDiff = collator.compare(aName, bName);
|
||||
if (nameDiff !== 0) return nameDiff;
|
||||
} else if (effectiveStrategy === "created") {
|
||||
const createdDiff = bCreated - aCreated;
|
||||
if (createdDiff !== 0) return createdDiff;
|
||||
} else {
|
||||
// recent
|
||||
const recentDiff = bRecent - aRecent;
|
||||
if (recentDiff !== 0) return recentDiff;
|
||||
}
|
||||
|
||||
// Tie-breakers: name -> created -> recent
|
||||
const fallbackNameDiff = collator.compare(aName, bName);
|
||||
if (fallbackNameDiff !== 0) return fallbackNameDiff;
|
||||
|
||||
const fallbackCreatedDiff = bCreated - aCreated;
|
||||
if (fallbackCreatedDiff !== 0) return fallbackCreatedDiff;
|
||||
|
||||
const fallbackRecentDiff = bRecent - aRecent;
|
||||
if (fallbackRecentDiff !== 0) return fallbackRecentDiff;
|
||||
|
||||
return 0;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a new array sorted by the provided {@link SortStrategy}.
|
||||
*/
|
||||
export function sortByStrategy<T>(
|
||||
items: readonly T[],
|
||||
strategy: SortStrategy,
|
||||
getters: RecentUsageSortGetters<T>
|
||||
): T[] {
|
||||
return [...items].sort(createSortComparator(strategy, getters));
|
||||
}
|
||||
|
||||
export interface TouchThrottleOptions {
|
||||
/**
|
||||
* Minimum time between two persisted touches for the same key (per session).
|
||||
* Defaults to 30 seconds.
|
||||
*/
|
||||
minIntervalMs?: number;
|
||||
|
||||
/**
|
||||
* Clock source for testing.
|
||||
*/
|
||||
nowMs?: () => number;
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory "touch" manager with throttled persistence.
|
||||
*
|
||||
* Key design: separates "memory update" from "persistence decision":
|
||||
* - `touch()` always updates memory and returns current timestamp (for UI sorting)
|
||||
* - `shouldPersist()` decides if persistence is needed (throttled, side-effect free)
|
||||
* - `markPersisted()` records a successful persistence after the side-effect succeeds
|
||||
* - `getLastTouchedAt()` returns memory value (for sorting, takes priority over persisted)
|
||||
* - `subscribe()` / `getRevision()` enable UI re-sorting when memory changes
|
||||
*
|
||||
* This ensures UI always reflects the latest access order, while disk writes are throttled.
|
||||
*/
|
||||
export class RecentUsageManager<Key extends string = string> {
|
||||
private readonly minIntervalMs: number;
|
||||
private readonly nowMs: () => number;
|
||||
private readonly lastTouchedAtMsByKey: Map<Key, number> = new Map();
|
||||
private readonly lastPersistedAtMsByKey: Map<Key, number> = new Map();
|
||||
private revision = 0;
|
||||
private readonly listeners: Set<() => void> = new Set();
|
||||
|
||||
constructor(options: TouchThrottleOptions = {}) {
|
||||
this.minIntervalMs = options.minIntervalMs ?? 30_000;
|
||||
this.nowMs = options.nowMs ?? (() => Date.now());
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify subscribers after in-memory state changes.
|
||||
*/
|
||||
private notifyChange(): void {
|
||||
this.revision += 1;
|
||||
for (const listener of this.listeners) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a monotonically increasing revision number that changes whenever
|
||||
* in-memory touch timestamps change. Useful as a React dependency.
|
||||
*/
|
||||
getRevision(): number {
|
||||
return this.revision;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to in-memory changes (touch/clear).
|
||||
*
|
||||
* @param listener - Invoked after a change is recorded.
|
||||
* @returns Unsubscribe function.
|
||||
*/
|
||||
subscribe(listener: () => void): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a touch for the given key. Always updates memory and returns current timestamp.
|
||||
* Use this value for UI sorting to ensure immediate feedback.
|
||||
*
|
||||
* @param key - Stable key for the item (e.g. file path, project id).
|
||||
* @returns Current timestamp (always returns a value, never null).
|
||||
*/
|
||||
touch(key: Key): number {
|
||||
const now = this.nowMs();
|
||||
this.lastTouchedAtMsByKey.set(key, now);
|
||||
this.notifyChange();
|
||||
return now;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether the last touch should be persisted.
|
||||
*
|
||||
* This method is intentionally side-effect free. Call {@link markPersisted}
|
||||
* only after the persistence side-effect succeeds.
|
||||
*
|
||||
* @param key - Stable key for the item.
|
||||
* @param persistedLastUsedAtMs - The last-used timestamp already stored on disk.
|
||||
* @returns Timestamp to persist, or null if throttled.
|
||||
*/
|
||||
shouldPersist(key: Key, persistedLastUsedAtMs?: number | null): number | null {
|
||||
const lastTouched = this.lastTouchedAtMsByKey.get(key);
|
||||
if (!lastTouched) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const persisted = normalizeTimestampMs(persistedLastUsedAtMs);
|
||||
const lastPersisted = this.lastPersistedAtMsByKey.get(key);
|
||||
|
||||
// Use the most recent of persisted value and our last persistence record
|
||||
const effectiveLastPersisted = Math.max(persisted ?? 0, lastPersisted ?? 0);
|
||||
|
||||
// If never persisted before, always allow first persistence
|
||||
if (effectiveLastPersisted === 0) {
|
||||
return lastTouched;
|
||||
}
|
||||
|
||||
// Throttle: don't persist if within minIntervalMs of last persistence
|
||||
if (lastTouched - effectiveLastPersisted < this.minIntervalMs) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return lastTouched;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark that a touch was successfully persisted for throttling purposes.
|
||||
* Call this only after the persistence side-effect succeeds.
|
||||
*
|
||||
* @param key - Stable key for the item.
|
||||
* @param persistedAtMs - The timestamp that was persisted (epoch ms).
|
||||
*/
|
||||
markPersisted(key: Key, persistedAtMs: number): void {
|
||||
const normalized = normalizeTimestampMs(persistedAtMs);
|
||||
if (!normalized) {
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = this.lastPersistedAtMsByKey.get(key) ?? 0;
|
||||
this.lastPersistedAtMsByKey.set(key, Math.max(existing, normalized));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the in-memory last touched timestamp for a key.
|
||||
* Use this for sorting to ensure UI reflects the latest access order.
|
||||
*
|
||||
* @param key - Stable key for the item.
|
||||
* @returns In-memory timestamp, or null if never touched in this session.
|
||||
*/
|
||||
getLastTouchedAt(key: Key): number | null {
|
||||
return this.lastTouchedAtMsByKey.get(key) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the effective last used timestamp for sorting.
|
||||
* Prefers in-memory value over persisted value.
|
||||
*
|
||||
* @param key - Stable key for the item.
|
||||
* @param persistedLastUsedAtMs - The persisted timestamp from storage.
|
||||
* @returns The most recent timestamp (memory or persisted).
|
||||
*/
|
||||
getEffectiveLastUsedAt(key: Key, persistedLastUsedAtMs?: number | null): number {
|
||||
const memoryValue = this.lastTouchedAtMsByKey.get(key);
|
||||
const persistedValue = normalizeTimestampMs(persistedLastUsedAtMs);
|
||||
return Math.max(memoryValue ?? 0, persistedValue ?? 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear state for a specific key or for all keys.
|
||||
*/
|
||||
clear(key?: Key): void {
|
||||
if (key) {
|
||||
this.lastTouchedAtMsByKey.delete(key);
|
||||
this.lastPersistedAtMsByKey.delete(key);
|
||||
this.notifyChange();
|
||||
return;
|
||||
}
|
||||
this.lastTouchedAtMsByKey.clear();
|
||||
this.lastPersistedAtMsByKey.clear();
|
||||
this.notifyChange();
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue