logancyang_obsidian-copilot/src/components/modals/ProjectPatternMatchingModal.tsx
Zero Liu 8239443814
refactor(react): replace global app with useApp() hook in React layer (#2448)
First wave of eliminating global `app` references (per Obsidian's
`obsidianmd/no-restricted-globals` rule). Adds a `useApp()` hook that
reads from `AppContext` and throws if no provider is in scope, then
converts the React layer to use it. Modals that render React content
now wrap their tree with `<AppContext.Provider value={this.app}>` so
descendants pick up the modal-owned app rather than the global one,
which matters in popout windows.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 00:57:38 -07:00

297 lines
9.6 KiB
TypeScript

import { TruncatedText } from "@/components/TruncatedText";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
categorizePatterns,
createPatternSettingsValue,
getDecodedPatterns,
getExtensionPattern,
getFilePattern,
getTagPattern,
} from "@/search/searchUtils";
import { File, FileText, Folder, Tag, Wrench, X } from "lucide-react";
import { App, Modal, TFile } from "obsidian";
import React, { useState } from "react";
import { createRoot, Root } from "react-dom/client";
import { AppContext, useApp } from "@/context";
import { CustomPatternInputModal } from "./CustomPatternInputModal";
import { ExtensionInputModal } from "./ExtensionInputModal";
import { FolderSearchModal } from "./FolderSearchModal";
import { ProjectFileSelectModal } from "./ProjectFileSelectModal";
import { TagSearchModal } from "./TagSearchModal";
function PatternListGroup({
title,
patterns,
onRemove,
}: {
title: string;
patterns: string[];
onRemove: (pattern: string) => void;
}) {
return (
<div className="tw-grid tw-grid-cols-4 tw-gap-2">
<div className="tw-font-bold">{title}</div>
<ul className="tw-col-span-3 tw-m-0 tw-flex tw-list-inside tw-list-disc tw-flex-col tw-gap-1 tw-pl-0">
{patterns.map((pattern) => (
<li
key={pattern}
className="tw-flex tw-gap-2 tw-rounded-md tw-pl-2 tw-pr-1 hover:tw-bg-modifier-hover"
>
<TruncatedText className="tw-flex-1">{pattern}</TruncatedText>
<Button variant="ghost2" size="fit" onClick={() => onRemove(pattern)}>
<X className="tw-size-4" />
</Button>
</li>
))}
</ul>
</div>
);
}
function ProjectPatternMatchingModalContent({
value: initialValue,
onUpdate,
container,
}: {
value: string;
onUpdate: (value: string) => void;
container: HTMLElement;
}) {
const app = useApp();
const [value, setValue] = useState(initialValue);
const patterns = getDecodedPatterns(value);
const { tagPatterns, extensionPatterns, folderPatterns, notePatterns } =
categorizePatterns(patterns);
const updateCategories = (newCategories: {
tagPatterns?: string[];
extensionPatterns?: string[];
folderPatterns?: string[];
notePatterns?: string[];
}) => {
const newValue = createPatternSettingsValue({
tagPatterns: newCategories.tagPatterns ?? tagPatterns,
extensionPatterns: newCategories.extensionPatterns ?? extensionPatterns,
folderPatterns: newCategories.folderPatterns ?? folderPatterns,
notePatterns: newCategories.notePatterns ?? notePatterns,
});
setValue(newValue);
onUpdate(newValue);
};
const hasValue =
tagPatterns.length > 0 ||
extensionPatterns.length > 0 ||
folderPatterns.length > 0 ||
notePatterns.length > 0;
return (
<div className="tw-mt-2 tw-flex tw-flex-col tw-gap-4">
<div className="tw-flex tw-max-h-[400px] tw-flex-col tw-gap-2 tw-overflow-y-auto tw-rounded-md tw-border tw-border-solid tw-border-border tw-p-4">
{!hasValue && <div className="tw-text-center tw-text-sm">No patterns specified</div>}
{tagPatterns.length > 0 && (
<PatternListGroup
title="Tags"
patterns={tagPatterns}
onRemove={(pattern) => {
const newPatterns = tagPatterns.filter((p) => p !== pattern);
updateCategories({
tagPatterns: newPatterns,
});
}}
/>
)}
{extensionPatterns.length > 0 && (
<PatternListGroup
title="Extensions"
patterns={extensionPatterns}
onRemove={(pattern) => {
const newPatterns = extensionPatterns.filter((p) => p !== pattern);
updateCategories({
extensionPatterns: newPatterns,
});
}}
/>
)}
{folderPatterns.length > 0 && (
<PatternListGroup
title="Folders"
patterns={folderPatterns}
onRemove={(pattern) => {
const newPatterns = folderPatterns.filter((p) => p !== pattern);
updateCategories({
folderPatterns: newPatterns,
});
}}
/>
)}
{notePatterns.length > 0 && (
<PatternListGroup
title="Files"
patterns={notePatterns}
onRemove={(pattern) => {
const newPatterns = notePatterns.filter((p) => p !== pattern);
updateCategories({
notePatterns: newPatterns,
});
}}
/>
)}
</div>
<div className="tw-flex tw-justify-end tw-gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="secondary">Add...</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" container={container}>
<DropdownMenuItem
onSelect={() => {
new TagSearchModal(app, (tag) => {
const tagPattern = getTagPattern(tag);
if (tagPatterns.includes(tagPattern)) {
return;
}
updateCategories({
tagPatterns: [...tagPatterns, tagPattern],
});
}).open();
}}
>
<div className="tw-flex tw-items-center tw-gap-2">
<Tag className="tw-size-4" />
Tag
</div>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
new FolderSearchModal(app, (folder) => {
if (folderPatterns.includes(folder)) {
return;
}
updateCategories({
folderPatterns: [...folderPatterns, folder],
});
}).open();
}}
>
<div className="tw-flex tw-items-center tw-gap-2">
<Folder className="tw-size-4" />
Folder
</div>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
new ProjectFileSelectModal({
app,
onFileSelect: (file: TFile) => {
const filePattern = getFilePattern(file);
if (notePatterns.includes(filePattern)) {
return;
}
updateCategories({
notePatterns: [...notePatterns, filePattern],
});
},
excludeFilePaths: [],
titleOnly: true,
}).open();
}}
>
<div className="tw-flex tw-items-center tw-gap-2">
<FileText className="tw-size-4" />
Files
</div>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
new ExtensionInputModal(app, (extension) => {
const extensionPattern = getExtensionPattern(extension);
if (extensionPatterns.includes(extensionPattern)) {
return;
}
updateCategories({
extensionPatterns: [...extensionPatterns, extensionPattern],
});
}).open();
}}
>
<div className="tw-flex tw-items-center tw-gap-2">
<File className="tw-size-4" />
Extension
</div>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
new CustomPatternInputModal(app, (value) => {
const patterns = getDecodedPatterns(value);
const {
tagPatterns: newTagPatterns,
extensionPatterns: newExtensionPatterns,
folderPatterns: newFolderPatterns,
notePatterns: newNotePatterns,
} = categorizePatterns(patterns);
updateCategories({
tagPatterns: [...tagPatterns, ...newTagPatterns],
extensionPatterns: [...extensionPatterns, ...newExtensionPatterns],
folderPatterns: [...folderPatterns, ...newFolderPatterns],
notePatterns: [...notePatterns, ...newNotePatterns],
});
}).open();
}}
>
<div className="tw-flex tw-items-center tw-gap-2">
<Wrench className="tw-size-4" />
Custom
</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
);
}
export class ProjectPatternMatchingModal extends Modal {
private root: Root;
constructor(
app: App,
private onUpdate: (value: string) => void,
/** The raw pattern matching value, separated by commas */
private value: string,
title: string
) {
super(app);
// @ts-ignore
this.setTitle(title);
}
onOpen() {
const { contentEl } = this;
this.root = createRoot(contentEl);
const handleUpdate = (value: string) => {
this.onUpdate(value);
};
this.root.render(
<AppContext.Provider value={this.app}>
<ProjectPatternMatchingModalContent
value={this.value}
onUpdate={handleUpdate}
container={this.contentEl}
/>
</AppContext.Provider>
);
}
onClose() {
this.root.unmount();
}
}