mirror of
https://github.com/logancyang/obsidian-copilot.git
synced 2026-07-22 07:50:24 +00:00
chore(lint): fix no-array-index-key and other React lint warnings (#2453)
* chore(lint): fix no-array-index-key and other React lint warnings Re-enable @eslint-react/no-array-index-key, replace index keys with stable ids where possible (capability enum, action label, file paths, computed coords), and add per-line eslint-disable with rationale where index is intentional (diff parts, append-only steps, parsed markdown, duplicate- tolerant context badges). Also addresses adjacent React lint warnings: useMemo for context provider values, stable module-scope defaults for empty array props, lazy useState initializers, and explicit type="button" on non-submit buttons. Surface no-direct-set-state-in-use-effect as a warning for follow-up triage. * fix: dedupe selected image files
This commit is contained in:
parent
6ca2dc01ea
commit
13beaf133b
26 changed files with 219 additions and 75 deletions
|
|
@ -31,6 +31,21 @@ export default [
|
|||
files: ["**/*.{jsx,tsx}"],
|
||||
...eslintReact.configs.recommended,
|
||||
},
|
||||
{
|
||||
files: ["**/*.{jsx,tsx}"],
|
||||
rules: {
|
||||
// Deferred to follow-up PRs — these flag legitimate anti-patterns but
|
||||
// each fix requires per-component intent analysis, and they're surfaced
|
||||
// as warnings (not errors) so they don't block CI.
|
||||
//
|
||||
// no-direct-set-state-in-use-effect: ~50 violations. Common pattern is
|
||||
// "sync local state with prop", which has no one-size-fits-all fix —
|
||||
// some cases want render-time derivation, others want a `key` prop reset
|
||||
// or `useSyncExternalStore`. Refactoring blindly risks behavior regressions
|
||||
// in the chat UI's stateful components.
|
||||
"@eslint-react/hooks-extra/no-direct-set-state-in-use-effect": "warn",
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["**/*.{js,jsx,mjs,cjs,ts,tsx}"],
|
||||
plugins: { "react-hooks": reactHooks },
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ import { FileParserManager } from "@/tools/FileParserManager";
|
|||
import { ChatMessage } from "@/types/message";
|
||||
import { err2String, isPlusChain } from "@/utils";
|
||||
import { arrayBufferToBase64 } from "@/utils/base64";
|
||||
import { appendUniqueFiles } from "@/utils/fileListUtils";
|
||||
import { Notice, TFile } from "obsidian";
|
||||
import { ContextManageModal } from "@/components/modals/project/context-manage-modal";
|
||||
import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
|
@ -241,13 +242,20 @@ const ChatInternal: React.FC<ChatProps & { chatInput: ReturnType<typeof useChatI
|
|||
const appContext = useContext(AppContext);
|
||||
const app = plugin.app || appContext;
|
||||
|
||||
/**
|
||||
* Add selected image files while preserving the original selection order.
|
||||
*/
|
||||
const handleAddImage = useCallback((files: File[]) => {
|
||||
setSelectedImages((prev) => appendUniqueFiles(prev, files));
|
||||
}, []);
|
||||
|
||||
// Drag-and-drop hook for file handling
|
||||
const { isDragActive } = useChatFileDrop({
|
||||
app,
|
||||
contextNotes,
|
||||
setContextNotes,
|
||||
selectedImages,
|
||||
onAddImage: (files) => setSelectedImages((prev) => [...prev, ...files]),
|
||||
onAddImage: handleAddImage,
|
||||
containerRef: chatContainerRef,
|
||||
});
|
||||
|
||||
|
|
@ -888,7 +896,7 @@ const ChatInternal: React.FC<ChatProps & { chatInput: ReturnType<typeof useChatI
|
|||
setIncludeActiveWebTab={setIncludeActiveWebTab}
|
||||
activeWebTab={currentActiveWebTab}
|
||||
selectedImages={selectedImages}
|
||||
onAddImage={(files: File[]) => setSelectedImages((prev) => [...prev, ...files])}
|
||||
onAddImage={handleAddImage}
|
||||
setSelectedImages={setSelectedImages}
|
||||
disableModelSwitch={selectedChain === ChainType.PROJECT_CHAIN}
|
||||
selectedTextContexts={selectedTextContexts}
|
||||
|
|
|
|||
|
|
@ -65,13 +65,13 @@ const CopilotSpinner: React.FC = () => {
|
|||
viewBox={`0 0 ${gridSize} ${gridSize}`}
|
||||
className="copilot-spinner"
|
||||
>
|
||||
{sigmaDots.map((dot, index) => {
|
||||
{sigmaDots.map((dot) => {
|
||||
const cx = dot.col * (dotSize + gap) + dotSize / 2;
|
||||
const cy = dot.row * (dotSize + gap) + dotSize / 2;
|
||||
|
||||
return (
|
||||
<circle
|
||||
key={index}
|
||||
key={`${dot.row}-${dot.col}`}
|
||||
cx={cx}
|
||||
cy={cy}
|
||||
r={dotSize / 2}
|
||||
|
|
@ -164,6 +164,7 @@ export const AgentReasoningBlock: React.FC<AgentReasoningBlockProps> = ({
|
|||
{steps.length > 0 && (
|
||||
<ul className="agent-reasoning-steps">
|
||||
{steps.map((step, i) => (
|
||||
// eslint-disable-next-line @eslint-react/no-array-index-key -- steps are append-only with no stable id; text may repeat
|
||||
<li key={i} className="agent-reasoning-step">
|
||||
{step}
|
||||
</li>
|
||||
|
|
|
|||
|
|
@ -22,6 +22,8 @@ import { mergeWebTabContexts } from "@/utils/urlNormalization";
|
|||
import { AtMentionTypeahead } from "./AtMentionTypeahead";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
|
||||
const EMPTY_SELECTED_TEXT_CONTEXTS: SelectedTextContext[] = [];
|
||||
|
||||
interface ChatContextMenuProps {
|
||||
includeActiveNote: boolean;
|
||||
currentActiveFile: TFile | null;
|
||||
|
|
@ -104,7 +106,7 @@ export const ChatContextMenu: React.FC<ChatContextMenuProps> = ({
|
|||
contextUrls,
|
||||
contextFolders,
|
||||
contextWebTabs,
|
||||
selectedTextContexts = [],
|
||||
selectedTextContexts = EMPTY_SELECTED_TEXT_CONTEXTS,
|
||||
onRemoveContext,
|
||||
showProgressCard,
|
||||
showIndexingCard,
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import {
|
|||
import { useSettingsValue } from "@/settings/model";
|
||||
import { SelectedTextContext, WebTabContext } from "@/types/message";
|
||||
import { isAllowedFileForNoteContext } from "@/utils";
|
||||
import { getFileIdentityKey } from "@/utils/fileListUtils";
|
||||
import { CornerDownLeft, Image, Loader2, StopCircle, X } from "lucide-react";
|
||||
import { App, Notice, TFile, TFolder } from "obsidian";
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
|
@ -745,13 +746,14 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
|||
{selectedImages.length > 0 && (
|
||||
<div className="selected-images">
|
||||
{selectedImages.map((file, index) => (
|
||||
<div key={index} className="image-preview-container">
|
||||
<div key={getFileIdentityKey(file)} className="image-preview-container">
|
||||
<img
|
||||
src={URL.createObjectURL(file)}
|
||||
alt={file.name}
|
||||
className="selected-image-preview"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="remove-image-button"
|
||||
onClick={() => setSelectedImages((prev) => prev.filter((_, i) => i !== index))}
|
||||
title="Remove image"
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@ export function ChatSettingsPopover() {
|
|||
: "";
|
||||
|
||||
// Read state from session atom
|
||||
const [disableBuiltin, setDisableBuiltin] = useState(getDisableBuiltinSystemPrompt());
|
||||
const [disableBuiltin, setDisableBuiltin] = useState(() => getDisableBuiltinSystemPrompt());
|
||||
const [showConfirmation, setShowConfirmation] = useState(false);
|
||||
const confirmationRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
|
|
|
|||
|
|
@ -215,6 +215,7 @@ function MessageContext({ context }: { context: ChatMessage["context"] }) {
|
|||
return (
|
||||
<div className="tw-flex tw-flex-wrap tw-gap-2">
|
||||
{context.notes.map((note, index) => (
|
||||
// eslint-disable-next-line @eslint-react/no-array-index-key -- context arrays may contain duplicate notes (see MessageContext.test.tsx duplicate-handling cases)
|
||||
<Tooltip key={`note-${index}-${note.path}`}>
|
||||
<TooltipTrigger asChild>
|
||||
<div>
|
||||
|
|
@ -225,6 +226,7 @@ function MessageContext({ context }: { context: ChatMessage["context"] }) {
|
|||
</Tooltip>
|
||||
))}
|
||||
{context.urls.map((url, index) => (
|
||||
// eslint-disable-next-line @eslint-react/no-array-index-key -- context arrays may contain duplicate urls (see MessageContext.test.tsx duplicate-handling cases)
|
||||
<Tooltip key={`url-${index}-${url}`}>
|
||||
<TooltipTrigger asChild>
|
||||
<div>
|
||||
|
|
@ -235,6 +237,7 @@ function MessageContext({ context }: { context: ChatMessage["context"] }) {
|
|||
</Tooltip>
|
||||
))}
|
||||
{context.webTabs?.map((webTab, index) => (
|
||||
// eslint-disable-next-line @eslint-react/no-array-index-key -- context arrays may contain duplicates; index disambiguates same-url entries
|
||||
<Tooltip key={`webTab-${index}-${webTab.url}`}>
|
||||
<TooltipTrigger asChild>
|
||||
<div>
|
||||
|
|
@ -254,6 +257,7 @@ function MessageContext({ context }: { context: ChatMessage["context"] }) {
|
|||
</Tooltip>
|
||||
))}
|
||||
{context.tags?.map((tag, index) => (
|
||||
// eslint-disable-next-line @eslint-react/no-array-index-key -- context arrays may contain duplicates; index disambiguates same-value entries
|
||||
<Tooltip key={`tag-${index}-${tag}`}>
|
||||
<TooltipTrigger asChild>
|
||||
<div>
|
||||
|
|
@ -264,6 +268,7 @@ function MessageContext({ context }: { context: ChatMessage["context"] }) {
|
|||
</Tooltip>
|
||||
))}
|
||||
{context.folders?.map((folder, index) => (
|
||||
// eslint-disable-next-line @eslint-react/no-array-index-key -- context arrays may contain duplicates; index disambiguates same-value entries
|
||||
<Tooltip key={`folder-${index}-${folder}`}>
|
||||
<TooltipTrigger asChild>
|
||||
<div>
|
||||
|
|
@ -274,6 +279,7 @@ function MessageContext({ context }: { context: ChatMessage["context"] }) {
|
|||
</Tooltip>
|
||||
))}
|
||||
{context.selectedTextContexts?.map((selectedText, index) => (
|
||||
// eslint-disable-next-line @eslint-react/no-array-index-key -- context arrays may contain duplicates; index disambiguates same-id entries
|
||||
<Tooltip key={`selectedText-${index}-${selectedText.id}`}>
|
||||
<TooltipTrigger asChild>
|
||||
<div>
|
||||
|
|
@ -864,7 +870,8 @@ const ChatSingleMessage: React.FC<ChatSingleMessageProps> = ({
|
|||
// Set unmounting flag immediately
|
||||
isUnmountingRef.current = true;
|
||||
|
||||
// Defer cleanup to avoid React rendering conflicts
|
||||
// Defer cleanup to avoid React rendering conflicts.
|
||||
// eslint-disable-next-line @eslint-react/web-api/no-leaked-timeout -- fire-and-forget defer; no cleanup target available inside an effect-cleanup
|
||||
window.setTimeout(() => {
|
||||
// Clean up component
|
||||
if (currentComponentRef.current) {
|
||||
|
|
@ -924,6 +931,7 @@ const ChatSingleMessage: React.FC<ChatSingleMessageProps> = ({
|
|||
(item, index) => {
|
||||
if (item.type === "text") {
|
||||
return (
|
||||
// eslint-disable-next-line @eslint-react/no-array-index-key -- content array is fixed once message is rendered; items not reordered
|
||||
<div key={index}>
|
||||
{message.sender === USER_SENDER ? (
|
||||
<div className="tw-whitespace-pre-wrap tw-break-words tw-text-[calc(var(--font-text-size)_-_2px)] tw-font-normal">
|
||||
|
|
@ -939,6 +947,7 @@ const ChatSingleMessage: React.FC<ChatSingleMessageProps> = ({
|
|||
);
|
||||
} else if (item.type === "image_url") {
|
||||
return (
|
||||
// eslint-disable-next-line @eslint-react/no-array-index-key -- content array is fixed once message is rendered; items not reordered
|
||||
<div key={index} className="message-image-content">
|
||||
<img
|
||||
src={item.image_url!.url}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { TFile, App } from "obsidian";
|
|||
import ChatInput from "./ChatInput";
|
||||
import { ChatMessage } from "@/types/message";
|
||||
import { useActiveWebTabState } from "./hooks/useActiveWebTabState";
|
||||
import { appendUniqueFiles } from "@/utils/fileListUtils";
|
||||
|
||||
interface InlineMessageEditorProps {
|
||||
/** The initial message text to edit */
|
||||
|
|
@ -78,8 +79,11 @@ export const InlineMessageEditor: React.FC<InlineMessageEditorProps> = ({
|
|||
// Not used in edit mode
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Add selected image files while preserving the original selection order.
|
||||
*/
|
||||
const handleAddImage = useCallback((files: File[]) => {
|
||||
setSelectedImages((prev) => [...prev, ...files]);
|
||||
setSelectedImages((prev) => appendUniqueFiles(prev, files));
|
||||
}, []);
|
||||
|
||||
const handleRemoveSelectedText = useCallback((id: string) => {
|
||||
|
|
|
|||
|
|
@ -31,11 +31,13 @@ function MessageContext({ context }: { context: ChatMessage["context"] }) {
|
|||
return (
|
||||
<div className="tw-flex tw-flex-wrap tw-gap-2">
|
||||
{context.notes.map((note, index) => (
|
||||
// eslint-disable-next-line @eslint-react/no-array-index-key -- mirrors the production component; context arrays may contain duplicates
|
||||
<div key={`${index}-${note.path}`} data-testid="note-badge">
|
||||
<span>{note.basename}</span>
|
||||
</div>
|
||||
))}
|
||||
{context.urls.map((url, index) => (
|
||||
// eslint-disable-next-line @eslint-react/no-array-index-key -- mirrors the production component; context arrays may contain duplicates
|
||||
<div key={`${index}-${url}`} data-testid="url-badge">
|
||||
<span>{url}</span>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -252,12 +252,14 @@ function RelevantNotePopover({
|
|||
<span className="tw-text-xs tw-text-muted">{note.note.path}</span>
|
||||
<div className="tw-flex tw-gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAddToChat}
|
||||
className="tw-inline-flex tw-items-center tw-gap-2 tw-border tw-border-solid tw-border-border !tw-bg-transparent !tw-shadow-none hover:!tw-bg-interactive-hover"
|
||||
>
|
||||
Add to Chat <PlusCircle className="tw-size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
const openInNewLeaf = e.metaKey || e.ctrlKey;
|
||||
onNavigateToNote(openInNewLeaf);
|
||||
|
|
|
|||
|
|
@ -104,9 +104,9 @@ export const SuggestedPrompts: React.FC<SuggestedPromptsProps> = ({ onClick }) =
|
|||
</CardHeader>
|
||||
<CardContent className="tw-p-2 tw-pt-0">
|
||||
<div className="tw-flex tw-flex-col tw-gap-2">
|
||||
{prompts.map((prompt, i) => (
|
||||
{prompts.map((prompt) => (
|
||||
<div
|
||||
key={i}
|
||||
key={prompt.text}
|
||||
className="tw-flex tw-justify-between tw-gap-2 tw-rounded-md tw-border tw-border-solid tw-border-border tw-p-2 tw-text-sm"
|
||||
>
|
||||
<div className="tw-flex tw-flex-col tw-gap-1">
|
||||
|
|
|
|||
|
|
@ -133,6 +133,7 @@ export const ToolCallBanner: React.FC<ToolCallBannerProps> = ({
|
|||
{!actuallyExecuting && onAccept && onReject && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onAccept();
|
||||
|
|
@ -143,6 +144,7 @@ export const ToolCallBanner: React.FC<ToolCallBannerProps> = ({
|
|||
<Check className="tw-size-4 tw-text-success" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onReject();
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { createContext, useContext } from "react";
|
||||
import React, { createContext, useContext, useMemo } from "react";
|
||||
import { TFile } from "obsidian";
|
||||
|
||||
/**
|
||||
|
|
@ -36,9 +36,6 @@ interface ActiveFileProviderProps {
|
|||
* to all descendant components
|
||||
*/
|
||||
export function ActiveFileProvider({ currentActiveFile, children }: ActiveFileProviderProps) {
|
||||
return (
|
||||
<ActiveFileContext.Provider value={{ currentActiveFile }}>
|
||||
{children}
|
||||
</ActiveFileContext.Provider>
|
||||
);
|
||||
const value = useMemo(() => ({ currentActiveFile }), [currentActiveFile]);
|
||||
return <ActiveFileContext.Provider value={value}>{children}</ActiveFileContext.Provider>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ export function NoteCommandPlugin({
|
|||
|
||||
// Use shared preview cache
|
||||
const [previewCache] = useState(() => new NotePreviewCache());
|
||||
const [previewContent, setPreviewContent] = useState<Map<string, string>>(new Map());
|
||||
const [previewContent, setPreviewContent] = useState<Map<string, string>>(() => new Map());
|
||||
|
||||
// Function to load note content for preview using shared cache
|
||||
const loadNoteContent = useCallback(
|
||||
|
|
|
|||
|
|
@ -131,6 +131,7 @@ const WordDiffSpan: React.FC<WordDiffSpanProps> = memo(({ original, modified, si
|
|||
if (side === "original") {
|
||||
if (part.removed) {
|
||||
return (
|
||||
// eslint-disable-next-line @eslint-react/no-array-index-key -- diff parts are computed once per render and not reordered
|
||||
<span key={idx} className="tw-bg-error tw-text-error">
|
||||
{part.value}
|
||||
</span>
|
||||
|
|
@ -140,6 +141,7 @@ const WordDiffSpan: React.FC<WordDiffSpanProps> = memo(({ original, modified, si
|
|||
} else {
|
||||
if (part.added) {
|
||||
return (
|
||||
// eslint-disable-next-line @eslint-react/no-array-index-key -- diff parts are computed once per render and not reordered
|
||||
<span key={idx} className="tw-bg-success tw-text-success">
|
||||
{part.value}
|
||||
</span>
|
||||
|
|
@ -147,6 +149,7 @@ const WordDiffSpan: React.FC<WordDiffSpanProps> = memo(({ original, modified, si
|
|||
}
|
||||
if (part.removed) return null;
|
||||
}
|
||||
// eslint-disable-next-line @eslint-react/no-array-index-key -- diff parts are computed once per render and not reordered
|
||||
return <span key={idx}>{part.value}</span>;
|
||||
})}
|
||||
</span>
|
||||
|
|
@ -285,6 +288,7 @@ const SideBySideBlock = memo(({ block }: SideBySideBlockProps) => {
|
|||
{/* Original (left) column */}
|
||||
<div className="tw-rounded-md tw-border tw-border-solid tw-border-border tw-bg-primary tw-p-2">
|
||||
{rows.map((row, idx) => (
|
||||
// eslint-disable-next-line @eslint-react/no-array-index-key -- diff rows are computed once per block and not reordered
|
||||
<div key={idx} className="tw-whitespace-pre-wrap tw-font-mono tw-text-sm">
|
||||
<DiffCell row={row} side="original" />
|
||||
</div>
|
||||
|
|
@ -294,6 +298,7 @@ const SideBySideBlock = memo(({ block }: SideBySideBlockProps) => {
|
|||
{/* Modified (right) column */}
|
||||
<div className="tw-rounded-md tw-border tw-border-solid tw-border-border tw-bg-primary tw-p-2">
|
||||
{rows.map((row, idx) => (
|
||||
// eslint-disable-next-line @eslint-react/no-array-index-key -- diff rows are computed once per block and not reordered
|
||||
<div key={idx} className="tw-whitespace-pre-wrap tw-font-mono tw-text-sm">
|
||||
<DiffCell row={row} side="modified" />
|
||||
</div>
|
||||
|
|
@ -319,6 +324,7 @@ const SplitBlock = memo(({ block }: SplitBlockProps) => {
|
|||
return (
|
||||
<div className="tw-whitespace-pre-wrap tw-px-2 tw-py-1 tw-font-mono tw-text-sm tw-text-normal">
|
||||
{block.map((change, idx) => (
|
||||
// eslint-disable-next-line @eslint-react/no-array-index-key -- block changes are computed once per render and not reordered
|
||||
<span key={idx}>{change.value}</span>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -333,6 +339,7 @@ const SplitBlock = memo(({ block }: SplitBlockProps) => {
|
|||
<div className="tw-whitespace-pre-wrap tw-font-mono tw-text-sm">
|
||||
{rows.map((row, idx) =>
|
||||
row.original !== null ? (
|
||||
// eslint-disable-next-line @eslint-react/no-array-index-key -- diff rows are computed once per block and not reordered
|
||||
<div key={idx}>
|
||||
<DiffCell row={row} side="original" />
|
||||
</div>
|
||||
|
|
@ -347,6 +354,7 @@ const SplitBlock = memo(({ block }: SplitBlockProps) => {
|
|||
<div className="tw-whitespace-pre-wrap tw-font-mono tw-text-sm">
|
||||
{rows.map((row, idx) =>
|
||||
row.modified !== null ? (
|
||||
// eslint-disable-next-line @eslint-react/no-array-index-key -- diff rows are computed once per block and not reordered
|
||||
<div key={idx}>
|
||||
<DiffCell row={row} side="modified" />
|
||||
</div>
|
||||
|
|
@ -616,6 +624,7 @@ const ApplyViewRoot: React.FC<ApplyViewRootProps> = ({ app, state, close }) => {
|
|||
|
||||
return (
|
||||
<div
|
||||
// eslint-disable-next-line @eslint-react/no-array-index-key -- changeBlocks is computed once from the diff and not reordered; blockIndex also keys blockRefs
|
||||
key={blockIndex}
|
||||
ref={(el) => (blockRefs.current[blockIndex] = el)}
|
||||
className={cn("tw-mb-4 tw-overflow-hidden tw-rounded-md")}
|
||||
|
|
@ -626,6 +635,7 @@ const ApplyViewRoot: React.FC<ApplyViewRootProps> = ({ app, state, close }) => {
|
|||
{block
|
||||
.filter((change) => !change.removed)
|
||||
.map((change, idx) => (
|
||||
// eslint-disable-next-line @eslint-react/no-array-index-key -- block changes are computed once per render and not reordered
|
||||
<div key={idx}>{change.value}</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -635,6 +645,7 @@ const ApplyViewRoot: React.FC<ApplyViewRootProps> = ({ app, state, close }) => {
|
|||
{block
|
||||
.filter((change) => !change.added)
|
||||
.map((change, idx) => (
|
||||
// eslint-disable-next-line @eslint-react/no-array-index-key -- block changes are computed once per render and not reordered
|
||||
<div key={idx}>{change.value}</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -51,25 +51,26 @@ function AddProjectModalContent({
|
|||
});
|
||||
|
||||
const [formData, setFormData] = useState<ProjectConfig>(
|
||||
initialProject || {
|
||||
id: randomUUID(),
|
||||
name: "",
|
||||
description: "",
|
||||
systemPrompt: "",
|
||||
projectModelKey: "",
|
||||
modelConfigs: {
|
||||
temperature: DEFAULT_MODEL_SETTING.TEMPERATURE,
|
||||
maxTokens: DEFAULT_MODEL_SETTING.MAX_TOKENS,
|
||||
},
|
||||
contextSource: {
|
||||
inclusions: "",
|
||||
exclusions: "",
|
||||
webUrls: "",
|
||||
youtubeUrls: "",
|
||||
},
|
||||
created: Date.now(),
|
||||
UsageTimestamps: Date.now(),
|
||||
}
|
||||
() =>
|
||||
initialProject || {
|
||||
id: randomUUID(),
|
||||
name: "",
|
||||
description: "",
|
||||
systemPrompt: "",
|
||||
projectModelKey: "",
|
||||
modelConfigs: {
|
||||
temperature: DEFAULT_MODEL_SETTING.TEMPERATURE,
|
||||
maxTokens: DEFAULT_MODEL_SETTING.MAX_TOKENS,
|
||||
},
|
||||
contextSource: {
|
||||
inclusions: "",
|
||||
exclusions: "",
|
||||
webUrls: "",
|
||||
youtubeUrls: "",
|
||||
},
|
||||
created: Date.now(),
|
||||
UsageTimestamps: Date.now(),
|
||||
}
|
||||
);
|
||||
|
||||
// URL items derived from formData for UrlTagInput
|
||||
|
|
|
|||
|
|
@ -218,9 +218,9 @@ export default function ProgressCard({ plugin, setHiddenCard, onEditContext }: P
|
|||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{mdProcessingFiles.map((path, i) => (
|
||||
{mdProcessingFiles.map((path) => (
|
||||
<div
|
||||
key={`md-proc-${i}`}
|
||||
key={`md-proc-${path}`}
|
||||
className="tw-flex tw-items-center tw-gap-2 tw-rounded-md tw-border tw-border-border tw-bg-primary tw-p-2"
|
||||
>
|
||||
<Loader2 className="tw-size-3.5 tw-animate-spin tw-text-loading" />
|
||||
|
|
@ -232,9 +232,9 @@ export default function ProgressCard({ plugin, setHiddenCard, onEditContext }: P
|
|||
</TruncatedText>
|
||||
</div>
|
||||
))}
|
||||
{mdFailedFiles.map((item, i) => (
|
||||
{mdFailedFiles.map((item) => (
|
||||
<div
|
||||
key={`md-fail-${i}`}
|
||||
key={`md-fail-${item.path}`}
|
||||
className="tw-rounded-md tw-border tw-border-border tw-bg-primary tw-p-2"
|
||||
>
|
||||
<div className="tw-flex tw-items-center tw-gap-2">
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ export interface MobileCardDropdownAction<T = object> {
|
|||
variant?: "default" | "destructive";
|
||||
}
|
||||
|
||||
const EMPTY_DROPDOWN_ACTIONS: [] = [];
|
||||
|
||||
export interface MobileCardProps<T extends object> {
|
||||
id: string;
|
||||
item: T;
|
||||
|
|
@ -53,7 +55,7 @@ export function MobileCard<T extends object>({
|
|||
defaultExpanded = false,
|
||||
expandedContent,
|
||||
primaryAction,
|
||||
dropdownActions = [],
|
||||
dropdownActions = EMPTY_DROPDOWN_ACTIONS,
|
||||
containerRef,
|
||||
className,
|
||||
onExpandToggle,
|
||||
|
|
@ -173,9 +175,9 @@ export function MobileCard<T extends object>({
|
|||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" container={containerRef?.current}>
|
||||
{dropdownActions.map((action, index) => (
|
||||
{dropdownActions.map((action) => (
|
||||
<DropdownMenuItem
|
||||
key={index}
|
||||
key={action.label}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void action.onClick(item);
|
||||
|
|
|
|||
|
|
@ -14,20 +14,22 @@ interface ModelCapabilityIconsProps {
|
|||
iconSize?: number;
|
||||
}
|
||||
|
||||
const EMPTY_CAPABILITIES: ModelCapability[] = [];
|
||||
|
||||
export const ModelCapabilityIcons: React.FC<ModelCapabilityIconsProps> = ({
|
||||
capabilities = [],
|
||||
capabilities = EMPTY_CAPABILITIES,
|
||||
iconSize = 16,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
{capabilities
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
.map((cap, index) => {
|
||||
.map((cap) => {
|
||||
switch (cap) {
|
||||
case ModelCapability.REASONING:
|
||||
return (
|
||||
<Lightbulb
|
||||
key={index}
|
||||
key={cap}
|
||||
className="tw-text-model-capabilities-blue"
|
||||
style={{ width: iconSize, height: iconSize }}
|
||||
/>
|
||||
|
|
@ -35,7 +37,7 @@ export const ModelCapabilityIcons: React.FC<ModelCapabilityIconsProps> = ({
|
|||
case ModelCapability.VISION:
|
||||
return (
|
||||
<Eye
|
||||
key={index}
|
||||
key={cap}
|
||||
className="tw-text-model-capabilities-green"
|
||||
style={{ width: iconSize, height: iconSize }}
|
||||
/>
|
||||
|
|
@ -43,7 +45,7 @@ export const ModelCapabilityIcons: React.FC<ModelCapabilityIconsProps> = ({
|
|||
case ModelCapability.WEB_SEARCH:
|
||||
return (
|
||||
<Globe
|
||||
key={index}
|
||||
key={cap}
|
||||
className="tw-text-model-capabilities-blue"
|
||||
style={{ width: iconSize, height: iconSize }}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { createContext, useContext, useCallback, useState } from "react";
|
||||
import React, { createContext, useContext, useCallback, useMemo, useState } from "react";
|
||||
import { INSERT_TEXT_WITH_PILLS_COMMAND } from "@/components/chat-components/utils/lexicalTextUtils";
|
||||
import { LexicalEditor } from "lexical";
|
||||
|
||||
|
|
@ -59,12 +59,15 @@ export function ChatInputProvider({ children }: ChatInputProviderProps): JSX.Ele
|
|||
}
|
||||
}, [focusHandler]);
|
||||
|
||||
const contextValue: ChatInputContextType = {
|
||||
insertTextWithPills,
|
||||
focusInput,
|
||||
registerEditor,
|
||||
registerFocusHandler,
|
||||
};
|
||||
const contextValue = useMemo<ChatInputContextType>(
|
||||
() => ({
|
||||
insertTextWithPills,
|
||||
focusInput,
|
||||
registerEditor,
|
||||
registerFocusHandler,
|
||||
}),
|
||||
[insertTextWithPills, focusInput, registerEditor, registerFocusHandler]
|
||||
);
|
||||
|
||||
return <ChatInputContext.Provider value={contextValue}>{children}</ChatInputContext.Provider>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { createContext, useContext, useEffect, useState, useRef } from "react";
|
||||
import React, { createContext, useContext, useMemo, useState } from "react";
|
||||
|
||||
interface TabContextType {
|
||||
selectedTab: string;
|
||||
|
|
@ -10,22 +10,18 @@ const TabContext = createContext<TabContextType | undefined>(undefined);
|
|||
|
||||
export const TabProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [selectedTab, setSelectedTab] = useState("basic");
|
||||
const [modalContainer, setModalContainer] = useState<HTMLElement | null>(null);
|
||||
const hasInitialized = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasInitialized.current) {
|
||||
const modal = activeDocument.querySelector(".modal-container") as HTMLElement;
|
||||
setModalContainer(modal);
|
||||
hasInitialized.current = true;
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<TabContext.Provider value={{ selectedTab, setSelectedTab, modalContainer }}>
|
||||
{children}
|
||||
</TabContext.Provider>
|
||||
// Compute the modal container lazily once; activeDocument is stable at provider
|
||||
// mount inside an Obsidian modal, so avoid an effect that would re-render.
|
||||
const [modalContainer] = useState<HTMLElement | null>(() =>
|
||||
activeDocument.querySelector(".modal-container")
|
||||
);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ selectedTab, setSelectedTab, modalContainer }),
|
||||
[selectedTab, modalContainer]
|
||||
);
|
||||
|
||||
return <TabContext.Provider value={value}>{children}</TabContext.Provider>;
|
||||
};
|
||||
|
||||
export const useTab = () => {
|
||||
|
|
|
|||
|
|
@ -151,6 +151,7 @@ function ApiKeyModalContent({ onClose, onGoToModelTab }: ApiKeyModalContentProps
|
|||
configuration (Base URL, Deployment Name, etc.).
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onGoToModelTab();
|
||||
onClose();
|
||||
|
|
|
|||
|
|
@ -77,7 +77,7 @@ export const ModelAddDialog: React.FC<ModelAddDialogProps> = ({
|
|||
};
|
||||
|
||||
const [dialogElement, setDialogElement] = useState<HTMLDivElement | null>(null);
|
||||
const [isOpen, setIsOpen] = useState(hasRequiredExtraSettings(defaultProvider));
|
||||
const [isOpen, setIsOpen] = useState(() => hasRequiredExtraSettings(defaultProvider));
|
||||
const [isVerifying, setIsVerifying] = useState(false);
|
||||
const [verifyStatus, setVerifyStatus] = useState<"idle" | "success" | "failed">("idle");
|
||||
const [errors, setErrors] = useState<FormErrors>({
|
||||
|
|
@ -179,7 +179,7 @@ export const ModelAddDialog: React.FC<ModelAddDialogProps> = ({
|
|||
return baseModel;
|
||||
};
|
||||
|
||||
const [model, setModel] = useState<CustomModel>(getInitialModel());
|
||||
const [model, setModel] = useState<CustomModel>(() => getInitialModel());
|
||||
|
||||
/**
|
||||
* Updates model state and resets verify status when connection-related fields change.
|
||||
|
|
@ -207,7 +207,7 @@ export const ModelAddDialog: React.FC<ModelAddDialogProps> = ({
|
|||
};
|
||||
};
|
||||
|
||||
const [providerInfo, setProviderInfo] = useState<ProviderMetadata>(
|
||||
const [providerInfo, setProviderInfo] = useState<ProviderMetadata>(() =>
|
||||
getProviderInfo(defaultProvider)
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ function renderVerificationMessage(message: string): React.ReactNode {
|
|||
if (match) {
|
||||
return (
|
||||
<a
|
||||
// eslint-disable-next-line @eslint-react/no-array-index-key -- static one-shot render of a parsed markdown paragraph; nodes are not reordered
|
||||
key={j}
|
||||
href={match[2]}
|
||||
target="_blank"
|
||||
|
|
@ -59,6 +60,7 @@ function renderVerificationMessage(message: string): React.ReactNode {
|
|||
return part;
|
||||
});
|
||||
return (
|
||||
// eslint-disable-next-line @eslint-react/no-array-index-key -- static one-shot render of parsed markdown paragraphs; paragraphs are not reordered
|
||||
<p key={i} className={i > 0 ? "tw-mt-1" : ""}>
|
||||
{nodes}
|
||||
</p>
|
||||
|
|
|
|||
51
src/utils/fileListUtils.test.ts
Normal file
51
src/utils/fileListUtils.test.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { appendUniqueFiles, getFileIdentityKey } from "@/utils/fileListUtils";
|
||||
|
||||
describe("fileListUtils", () => {
|
||||
it("deduplicates appended files by stable file identity", () => {
|
||||
const existingFile = new File(["image"], "image.png", {
|
||||
type: "image/png",
|
||||
lastModified: 123,
|
||||
});
|
||||
const duplicateFile = new File(["image"], "image.png", {
|
||||
type: "image/png",
|
||||
lastModified: 123,
|
||||
});
|
||||
const newFile = new File(["other"], "other.png", {
|
||||
type: "image/png",
|
||||
lastModified: 456,
|
||||
});
|
||||
|
||||
const result = appendUniqueFiles([existingFile], [duplicateFile, newFile, duplicateFile]);
|
||||
|
||||
expect(result).toEqual([existingFile, newFile]);
|
||||
});
|
||||
|
||||
it("returns the original array when every incoming file is already present", () => {
|
||||
const existingFile = new File(["image"], "image.png", {
|
||||
type: "image/png",
|
||||
lastModified: 123,
|
||||
});
|
||||
const duplicateFile = new File(["image"], "image.png", {
|
||||
type: "image/png",
|
||||
lastModified: 123,
|
||||
});
|
||||
const existingFiles = [existingFile];
|
||||
|
||||
const result = appendUniqueFiles(existingFiles, [duplicateFile]);
|
||||
|
||||
expect(result).toBe(existingFiles);
|
||||
});
|
||||
|
||||
it("includes the file type in the identity key", () => {
|
||||
const pngFile = new File(["image"], "image", {
|
||||
type: "image/png",
|
||||
lastModified: 123,
|
||||
});
|
||||
const jpegFile = new File(["image"], "image", {
|
||||
type: "image/jpeg",
|
||||
lastModified: 123,
|
||||
});
|
||||
|
||||
expect(getFileIdentityKey(pngFile)).not.toBe(getFileIdentityKey(jpegFile));
|
||||
});
|
||||
});
|
||||
31
src/utils/fileListUtils.ts
Normal file
31
src/utils/fileListUtils.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
/**
|
||||
* Returns a stable identity key for a browser File object.
|
||||
*/
|
||||
export function getFileIdentityKey(file: File): string {
|
||||
return `${file.name}-${file.size}-${file.lastModified}-${file.type}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends incoming files while preserving order and skipping files already present.
|
||||
*/
|
||||
export function appendUniqueFiles(existingFiles: File[], incomingFiles: File[]): File[] {
|
||||
if (incomingFiles.length === 0) {
|
||||
return existingFiles;
|
||||
}
|
||||
|
||||
const seenKeys = new Set(existingFiles.map(getFileIdentityKey));
|
||||
const uniqueIncomingFiles = incomingFiles.filter((file) => {
|
||||
const key = getFileIdentityKey(file);
|
||||
if (seenKeys.has(key)) {
|
||||
return false;
|
||||
}
|
||||
seenKeys.add(key);
|
||||
return true;
|
||||
});
|
||||
|
||||
if (uniqueIncomingFiles.length === 0) {
|
||||
return existingFiles;
|
||||
}
|
||||
|
||||
return [...existingFiles, ...uniqueIncomingFiles];
|
||||
}
|
||||
Loading…
Reference in a new issue