Chore/upgrade2 (#656)

* upgrade rjsf

* Always enable devTools, even in test mode for debugging

* Update index.tsx

* lint

* fix: properly log error to log file

* fix: precalculate json plugin route and exclude it even before save

* Update WatchFileSystemAdaptor.ts

* fix: hooks.addHook usage wrong

* fix: tw5-typed

* fix: outdated rjsf usage

* refactor: cleanup fs watch logs

* v0.13.0-rc5

* fix: lint

* fix: unused

* fix: IFileInfo type

* fix: type

* fix: failed rjsf test
This commit is contained in:
lin onetwo 2025-11-23 12:31:02 +08:00 committed by GitHub
parent 9a98c7bc47
commit 256e0fcb65
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 1896 additions and 1691 deletions

View file

@ -22,7 +22,6 @@ and run `node node_modules/electron/install.js` manually.
Or `[FAILED: ENOENT: no such file or directory, stat 'C:\Users\linonetwo\Documents\repo-c\TidGi-Desktop\node_modules\.pnpm\node_modules\@radix-ui\react-compose-refs']`
Remove it by run `rm 'xxx/node_modules/.pnpm/node_modules/@types/lodash-es'` fixes it. Maybe pnpm install gets interrupted, and make a file-like symlink, get recognized as binary file. Remove it will work.
## An unhandled rejection has occurred inside Forge about node-abi
Solution: Update `@electron/rebuild` to latest version:

View file

@ -37,8 +37,8 @@ Feature: Create New Agent Workflow
When I type "" in "agent name input" element with selector "[data-testid='agent-name-input-field']"
# Advance to step 2 (Edit Prompt)
When I click on a "next button" element with selector "[data-testid='next-button']"
# Step 4: Verify second step content (Edit Prompt)
And I should see a "edit prompt title" element with selector "*:has-text('')"
# Step 4: Verify second step content (Edit Prompt) - using text selector as a diagnostic
And I should see a "edit prompt title" element with selector "h6:has-text('')"
# Step 4.1: Wait for PromptConfigForm to load
# Verify the PromptConfigForm is present with our new test id
And I should see a "prompt config form" element with selector "[data-testid='prompt-config-form']"
@ -51,9 +51,9 @@ Feature: Create New Agent Workflow
| [data-testid='prompt-config-form'] [role='tabpanel']:not([hidden]) button[title*=''], [data-testid='prompt-config-form'] [role='tabpanel']:not([hidden]) button svg[data-testid='ExpandMoreIcon'] |
| [data-testid='prompt-config-form'] [role='tabpanel']:not([hidden]) textarea[id*='_text']:not([readonly]) |
When I clear text in "system prompt text field" element with selector "[data-testid='prompt-config-form'] [role='tabpanel']:not([hidden]) textarea[id*='_text']:not([readonly])"
And I wait for 0.1 seconds for "clear to complete and DOM to stabilize"
When I type "" in "system prompt text field" element with selector "[data-testid='prompt-config-form'] [role='tabpanel']:not([hidden]) textarea[id*='_text']:not([readonly])"
# Wait for form content save to backend
And I wait for 0.2 seconds
And I wait for 0.2 seconds for "form content save to backend"
# Step 5: Advance to step 3 (Immediate Use)
When I click on a "next button" element with selector "[data-testid='next-button']"
# Step 6: Verify third step content (Immediate Use with chat interface)

View file

@ -20,7 +20,6 @@ When('I wait for the page to load completely', async function(this: ApplicationW
Then('I should see a(n) {string} element with selector {string}', async function(this: ApplicationWorld, elementComment: string, selector: string) {
const currentWindow = this.currentWindow;
try {
await currentWindow?.waitForSelector(selector, { timeout: 10000 });
const isVisible = await currentWindow?.isVisible(selector);

View file

@ -2,7 +2,7 @@
"name": "tidgi",
"productName": "TidGi",
"description": "Customizable personal knowledge-base with Github as unlimited storage and blogging platform.",
"version": "0.13.0-prerelease4",
"version": "0.13.0-rc5",
"license": "MPL 2.0",
"packageManager": "pnpm@10.18.2",
"scripts": {
@ -33,10 +33,10 @@
"author": "Lin Onetwo <linonetwo012@gmail.com>, Quang Lam <quang.lam2807@gmail.com>",
"main": ".vite/build/main.js",
"dependencies": {
"@ai-sdk/anthropic": "^2.0.35",
"@ai-sdk/deepseek": "^1.0.23",
"@ai-sdk/openai": "^2.0.53",
"@ai-sdk/openai-compatible": "^1.0.22",
"@ai-sdk/anthropic": "^2.0.45",
"@ai-sdk/deepseek": "^1.0.29",
"@ai-sdk/openai": "^2.0.71",
"@ai-sdk/openai-compatible": "^1.0.27",
"@algolia/autocomplete-js": "^1.19.4",
"@algolia/autocomplete-theme-classic": "^1.19.4",
"@dnd-kit/core": "6.3.1",
@ -46,75 +46,75 @@
"@dr.pogodin/react-helmet": "^3.0.4",
"@fontsource/roboto": "^5.2.8",
"@monaco-editor/react": "^4.7.0",
"@mui/icons-material": "^7.3.4",
"@mui/material": "^7.3.4",
"@mui/system": "^7.3.3",
"@mui/types": "^7.4.7",
"@mui/x-date-pickers": "^8.14.1",
"@rjsf/core": "6.0.0-beta.8",
"@rjsf/mui": "6.0.0-beta.10",
"@rjsf/utils": "6.0.0-beta.10",
"@rjsf/validator-ajv8": "6.0.0-beta.8",
"@mui/icons-material": "^7.3.5",
"@mui/material": "^7.3.5",
"@mui/system": "^7.3.5",
"@mui/types": "^7.4.8",
"@mui/x-date-pickers": "^8.19.0",
"@rjsf/core": "6.1.2",
"@rjsf/mui": "6.1.2",
"@rjsf/utils": "6.1.2",
"@rjsf/validator-ajv8": "6.1.2",
"@tomplum/react-git-log": "^3.5.0",
"ai": "^5.0.76",
"ai": "^5.0.98",
"ansi-to-html": "^0.7.2",
"app-path": "^4.0.0",
"beautiful-react-hooks": "5.0.3",
"best-effort-json-parser": "1.2.1",
"better-sqlite3": "^12.4.1",
"better-sqlite3": "^12.4.5",
"bluebird": "3.7.2",
"date-fns": "3.6.0",
"default-gateway": "6.0.3",
"dugite": "3.0.0-rc12",
"dugite": "3.0.0",
"electron-dl": "^4.0.0",
"electron-ipc-cat": "2.2.3",
"electron-settings": "5.0.0",
"electron-unhandled": "4.0.1",
"electron-window-state": "5.0.3",
"espree": "^10.4.0",
"espree": "^11.0.0",
"exponential-backoff": "^3.1.3",
"fs-extra": "11.3.2",
"git-sync-js": "^2.2.1",
"git-sync-js": "^2.3.0",
"graphql-hooks": "8.2.0",
"html-minifier-terser": "^7.2.0",
"i18next": "25.6.0",
"i18next": "25.6.3",
"i18next-electron-fs-backend": "3.0.3",
"i18next-fs-backend": "2.6.0",
"immer": "^10.1.3",
"i18next-fs-backend": "2.6.1",
"immer": "^10.2.0",
"intercept-stdout": "0.1.2",
"inversify": "7.10.3",
"inversify": "7.10.4",
"ipaddr.js": "2.2.0",
"jimp": "1.6.0",
"json5": "^2.2.3",
"lodash": "4.17.21",
"material-ui-popup-state": "^5.3.6",
"menubar": "9.5.2",
"monaco-editor": "^0.54.0",
"monaco-editor": "^0.55.1",
"nanoid": "^5.1.6",
"new-github-issue-url": "^1.1.0",
"node-fetch": "3.3.2",
"nsfw": "^2.2.5",
"oidc-client-ts": "^3.3.0",
"ollama-ai-provider-v2": "^1.5.1",
"oidc-client-ts": "^3.4.1",
"ollama-ai-provider-v2": "^1.5.5",
"react": "19.2.0",
"react-dom": "19.2.0",
"react-i18next": "16.1.3",
"react-i18next": "16.3.5",
"react-masonry-css": "^1.0.16",
"react-window": "^2.2.1",
"react-window": "^2.2.3",
"reflect-metadata": "0.2.2",
"registry-js": "1.16.1",
"rotating-file-stream": "^3.2.7",
"rxjs": "7.8.2",
"semver": "7.7.3",
"serialize-error": "^12.0.0",
"simplebar": "6.3.2",
"simplebar": "6.3.3",
"simplebar-react": "3.3.2",
"source-map-support": "0.5.21",
"sqlite-vec": "0.1.7-alpha.2",
"strip-ansi": "^7.1.2",
"tapable": "^2.3.0",
"tiddlywiki": "5.3.8",
"type-fest": "5.1.0",
"type-fest": "5.2.0",
"typeorm": "^0.3.27",
"typescript-styled-is": "^2.1.0",
"v8-compile-cache-lib": "^3.0.1",
@ -153,37 +153,37 @@
"@types/html-minifier-terser": "^7.0.2",
"@types/intercept-stdout": "0.1.4",
"@types/lodash": "4.17.20",
"@types/node": "24.9.1",
"@types/react": "19.2.2",
"@types/react-dom": "19.2.2",
"@types/node": "24.10.1",
"@types/react": "19.2.6",
"@types/react-dom": "19.2.3",
"@types/react-jsonschema-form": "^1.7.13",
"@types/semver": "7.7.1",
"@types/source-map-support": "0.5.10",
"@vitejs/plugin-react": "^5.0.4",
"@vitejs/plugin-react": "^5.1.1",
"@vitest/coverage-v8": "^3.2.4",
"@vitest/ui": "^3.2.4",
"chai": "6.2.0",
"cross-env": "10.1.0",
"dprint": "^0.50.2",
"electron": "38.3.0",
"electron": "39.2.3",
"electron-chrome-web-store": "^0.13.0",
"esbuild": "^0.25.11",
"esbuild": "^0.27.0",
"eslint-config-tidgi": "^2.2.0",
"identity-obj-proxy": "^3.0.0",
"jsdom": "^27.0.1",
"jsdom": "^27.2.0",
"memory-fs": "^0.5.0",
"node-loader": "2.1.0",
"oauth2-mock-server": "^8.1.0",
"oauth2-mock-server": "^8.2.0",
"path-browserify": "^1.0.1",
"playwright": "^1.56.1",
"rimraf": "^6.0.1",
"rimraf": "^6.1.2",
"ts-node": "10.9.2",
"tsx": "^4.20.6",
"tw5-typed": "^0.6.8",
"tw5-typed": "^1.0.5",
"typescript": "5.9.3",
"typesync": "0.14.3",
"unplugin-swc": "^1.5.8",
"vite": "^7.1.11",
"vite": "^7.2.4",
"vite-bundle-analyzer": "^1.2.3",
"vitest": "^3.2.4"
},

2710
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,60 @@
import React, { Component, ErrorInfo, ReactNode } from 'react';
interface Props {
children: ReactNode;
fallback?: ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
}
export class ErrorBoundary extends Component<Props, State> {
public state: State = {
hasError: false,
error: null,
};
public static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
// Log error to console and backend
console.error('Uncaught error:', error, errorInfo);
// Forward to backend log
if (window.service?.native?.log) {
void window.service.native.log('error', `React Error Boundary caught: ${error.message}`, {
error: {
name: error.name,
message: error.message,
stack: error.stack,
},
componentStack: errorInfo.componentStack,
});
}
}
public render() {
if (this.state.hasError) {
if (this.props.fallback) {
return this.props.fallback;
}
return (
<div style={{ padding: '20px', color: 'red' }}>
<h2>Something went wrong.</h2>
<details style={{ whiteSpace: 'pre-wrap' }}>
{this.state.error?.toString()}
<br />
{this.state.error?.stack}
</details>
</div>
);
}
return this.props.children;
}
}

View file

@ -162,10 +162,13 @@ export const CreateNewAgentContent: React.FC<CreateNewAgentContentProps> = ({ ta
}
}, [temporaryAgentDefinition, saveToBackendDebounced]);
// Update current step when tab changes
// Only initialize from tab on mount, don't sync back
// The component is the source of truth for currentStep during its lifecycle
useEffect(() => {
setCurrentStep(tab.currentStep ?? 0);
}, [tab.currentStep]);
if (tab.currentStep !== undefined) {
setCurrentStep(tab.currentStep);
}
}, []); // Only run once on mount
// Cleanup when component unmounts or tab closes
useEffect(() => {
@ -359,26 +362,26 @@ export const CreateNewAgentContent: React.FC<CreateNewAgentContentProps> = ({ ta
case 'editPrompt':
return (
<StepContainer>
<Typography variant='h6' gutterBottom>
<Typography variant='h6' gutterBottom data-testid='edit-prompt-title'>
{t('CreateAgent.EditPrompt')}
</Typography>
<Typography variant='body2' color='text.secondary' gutterBottom>
{t('CreateAgent.EditPromptDescription')}
</Typography>
{temporaryAgentDefinition
{temporaryAgentDefinition && promptSchema
? (
<Box sx={{ mt: 2, height: 400, overflow: 'auto' }}>
<PromptConfigForm
schema={promptSchema || undefined}
formData={temporaryAgentDefinition.handlerConfig as HandlerConfig}
schema={promptSchema}
formData={(temporaryAgentDefinition.handlerConfig || {}) as HandlerConfig}
onChange={(updatedConfig) => {
void handleAgentDefinitionChange({
...temporaryAgentDefinition,
handlerConfig: updatedConfig as Record<string, unknown>,
});
}}
loading={!promptSchema}
loading={false}
/>
</Box>
)

View file

@ -219,22 +219,27 @@ describe('CreateNewAgentContent', () => {
it('should show correct step content based on currentStep', () => {
// Test step 1 (currentStep: 0) - Setup Agent (name + template)
const step1Tab = { ...mockTab, currentStep: 0 };
const { rerender } = render(<TestComponent tab={step1Tab} />);
const { unmount } = render(<TestComponent tab={step1Tab} />);
expect(screen.getByRole('heading', { name: '设置智能体' })).toBeInTheDocument();
expect(screen.getByLabelText('智能体名称')).toBeInTheDocument();
expect(screen.getByTestId('template-search-input')).toBeInTheDocument();
// Clean up before next render
unmount();
// Test step 2 (currentStep: 1) - Edit Prompt
const step2Tab = { ...mockTab, currentStep: 1 };
rerender(<TestComponent tab={step2Tab} />);
const { unmount: unmount2 } = render(<TestComponent tab={step2Tab} />);
// Should show editPrompt placeholder when no template selected
expect(screen.getByText('请先选择一个模板')).toBeInTheDocument();
unmount2();
// Test step 3 (currentStep: 2) - Immediate Use
const step3Tab = { ...mockTab, currentStep: 2 };
rerender(<TestComponent tab={step3Tab} />);
render(<TestComponent tab={step3Tab} />);
expect(screen.getByRole('heading', { name: '测试并使用' })).toBeInTheDocument();
});

View file

@ -1,82 +0,0 @@
import { closestCenter, DndContext, DragEndEvent, DragOverlay, DragStartEvent, KeyboardSensor, PointerSensor, useSensor, useSensors } from '@dnd-kit/core';
import { SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy } from '@dnd-kit/sortable';
import { ArrayFieldItemTemplateType } from '@rjsf/utils';
import React, { useState } from 'react';
interface DragAndDropProviderProps {
/** Array items to be sortable */
items: ArrayFieldItemTemplateType[];
/** Callback when items are reordered */
onReorder: (activeIndex: number, overIndex: number) => void;
/** Children components that will be draggable */
children: React.ReactNode;
}
/**
* Drag and drop provider for array items
* Features:
* - Keyboard and pointer sensor support
* - Visual drag overlay
* - Accessible drag and drop
*/
export const DragAndDropProvider: React.FC<DragAndDropProviderProps> = ({
items,
onReorder,
children,
}) => {
const [activeId, setActiveId] = useState<string | null>(null);
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
const handleDragStart = (event: DragStartEvent) => {
setActiveId(event.active.id as string);
};
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (over && active.id !== over.id) {
const activeIndex = items.findIndex(item => item.key === active.id);
const overIndex = items.findIndex(item => item.key === over.id);
if (activeIndex !== -1 && overIndex !== -1) {
onReorder(activeIndex, overIndex);
}
}
setActiveId(null);
};
const activeItem = items.find(item => item.key === activeId);
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<SortableContext
items={items.map(item => item.key)}
strategy={verticalListSortingStrategy}
>
{children}
</SortableContext>
<DragOverlay>
{activeItem
? (
<div style={{ opacity: 0.5 }}>
{activeItem.children}
</div>
)
: null}
</DragOverlay>
</DndContext>
);
};

View file

@ -1,147 +0,0 @@
import { useAgentChatStore } from '@/pages/Agent/store/agentChatStore';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import DeleteIcon from '@mui/icons-material/Delete';
import DragHandleIcon from '@mui/icons-material/DragHandle';
import { ArrayFieldItemTemplateType, FormContextType, RJSFSchema } from '@rjsf/utils';
import React, { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useShallow } from 'zustand/react/shallow';
import { ArrayItemProvider } from '../../context/ArrayItemContext';
import { StyledDeleteButton } from '../controls';
import { ArrayItemCard, ArrayItemHeader, ArrayItemTitle, DragHandle, ItemContent } from './StyledArrayContainer';
import { CollapseIcon, ExpandIcon, StyledCollapse, StyledExpandButton } from './StyledCollapsible';
/** Interface for sortable array item component props */
export interface SortableArrayItemProps<T = unknown, S extends RJSFSchema = RJSFSchema, F extends FormContextType = FormContextType> {
/** Array item data from RJSF */
item: ArrayFieldItemTemplateType<T, S, F>;
/** Index of this item in the array */
index: number;
/** Whether the item should be collapsible */
isCollapsible?: boolean;
/** Actual form data for this array item */
itemData?: unknown;
}
/**
* A sortable array item component with drag-and-drop functionality
* Features:
* - Drag handle for reordering
* - Item title with index
* - Delete button
* - Collapse/expand toggle (when isCollapsible is true)
* - Visual feedback when dragging
*/
export const SortableArrayItem = <T = unknown, S extends RJSFSchema = RJSFSchema, F extends FormContextType = FormContextType>({
item,
index,
isCollapsible = true,
itemData,
}: SortableArrayItemProps<T, S, F>) => {
const { t } = useTranslation('agent');
// Extract the actual ID from itemData instead of using path
const itemId = itemData && typeof itemData === 'object' && 'id' in itemData
? (itemData as { id: string }).id
: undefined;
const {
expanded,
setArrayItemExpanded,
} = useAgentChatStore(
useShallow((state) => ({
expanded: itemId ? state.isArrayItemExpanded(itemId) : false,
setArrayItemExpanded: state.setArrayItemExpanded,
})),
);
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({
id: item.key,
data: {
type: 'array-item',
index,
},
});
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
const handleToggleExpanded = useCallback(() => {
if (itemId) {
setArrayItemExpanded(itemId, !expanded);
}
}, [itemId, expanded, setArrayItemExpanded]);
const handleHeaderClick = useCallback((event: React.MouseEvent) => {
// Check if click target is clickable area (exclude buttons and drag handle)
const target = event.target as HTMLElement;
// Skip if clicking buttons or drag handle
if (target.closest('button') || target.closest('[data-drag-handle]')) {
return;
}
// Only handle click in collapsible mode
if (isCollapsible) {
handleToggleExpanded();
}
}, [isCollapsible, handleToggleExpanded]);
const handleDeleteClick = useCallback((event: React.MouseEvent) => {
event.stopPropagation(); // Prevent event bubbling to header
item.buttonsProps.onDropIndexClick(item.index)();
}, [item.buttonsProps, item.index]);
return (
<div ref={setNodeRef} style={style}>
<ArrayItemCard $isDragging={isDragging}>
<ArrayItemHeader
onClick={isCollapsible ? handleHeaderClick : undefined}
$isCollapsible={isCollapsible}
>
<DragHandle {...attributes} {...listeners} data-drag-handle>
<DragHandleIcon fontSize='small' />
</DragHandle>
<ArrayItemTitle sx={{ flex: 1 }}>
{itemData && typeof itemData === 'object' && 'caption' in itemData ? (itemData as { caption: string }).caption : ''}
</ArrayItemTitle>
{isCollapsible && (
<StyledExpandButton onClick={handleToggleExpanded}>
{expanded ? <CollapseIcon /> : <ExpandIcon />}
</StyledExpandButton>
)}
{item.buttonsProps.hasRemove && (
<StyledDeleteButton
onClick={handleDeleteClick}
size='small'
title={t('PromptConfig.RemoveItem')}
>
<DeleteIcon fontSize='small' />
</StyledDeleteButton>
)}
</ArrayItemHeader>
<StyledCollapse in={expanded} timeout='auto' unmountOnExit>
<ItemContent>
<ArrayItemProvider isInArrayItem={true} arrayItemCollapsible={isCollapsible}>
{item.children}
</ArrayItemProvider>
</ItemContent>
</StyledCollapse>
</ArrayItemCard>
</div>
);
};

View file

@ -1,5 +1,3 @@
export * from './DragAndDrop';
export * from './SortableArrayItem';
export * from './StyledArrayContainer';
export * from './StyledCard';
export * from './StyledCollapsible';

View file

@ -1,6 +1,6 @@
import { FieldProps } from '@rjsf/utils';
import type { FieldProps } from '@rjsf/utils';
import React, { useMemo } from 'react';
import { ConditionalFieldConfig, ExtendedFormContext } from '../index';
import type { ConditionalFieldConfig, ExtendedFormContext } from '../index';
/**
* ConditionalField wraps any field and conditionally shows/hides it based on sibling field values.
@ -12,7 +12,7 @@ import { ConditionalFieldConfig, ExtendedFormContext } from '../index';
* 4. Uses useMemo to prevent unnecessary recalculations
*/
export const ConditionalField: React.FC<FieldProps> = (props) => {
const { uiSchema, registry, idSchema } = props;
const { uiSchema, registry, fieldPathId } = props;
const condition = uiSchema?.['ui:condition'] as ConditionalFieldConfig | undefined;
@ -28,8 +28,10 @@ export const ConditionalField: React.FC<FieldProps> = (props) => {
if (!rootFormData) return true;
// Parse the field's path to find its parent object where sibling fields are located
const fieldPath = idSchema.$id.replace(/^root_/, '');
const pathParts = fieldPath.split('_');
// In RJSF 6.x, fieldPathId.$id is a string that contains the path
const fieldPathValue = fieldPathId?.$id ?? '';
const fieldPath = (typeof fieldPathValue === 'string' ? fieldPathValue : '').replace(/^root_/, '');
const pathParts = fieldPath.split('_').filter(Boolean);
pathParts.pop(); // Remove current field name to get parent path
// Navigate to parent object in the form data tree
@ -58,7 +60,7 @@ export const ConditionalField: React.FC<FieldProps> = (props) => {
// Apply inverse logic if specified
return hideWhen ? !conditionMet : conditionMet;
}, [condition, registry.formContext, idSchema.$id]);
}, [condition, registry.formContext, fieldPathId?.$id]);
// Hidden fields return nothing
if (!shouldShow) {

View file

@ -10,7 +10,7 @@ import { ErrorDisplay } from './components/ErrorDisplay';
import { ArrayItemProvider } from './context/ArrayItemContext';
import { useDefaultUiSchema } from './defaultUiSchema';
import { fields } from './fields';
import { ArrayFieldTemplate, FieldTemplate, ObjectFieldTemplate, RootObjectFieldTemplate } from './templates';
import { ArrayFieldItemTemplate, ArrayFieldTemplate, FieldTemplate, ObjectFieldTemplate, RootObjectFieldTemplate } from './templates';
import { widgets } from './widgets';
/**
@ -67,7 +67,7 @@ export const PromptConfigForm: React.FC<PromptConfigFormProps> = ({
const templates = useMemo(() => {
const rootObjectFieldTemplate = (props: ObjectFieldTemplateProps) => {
const isRootLevel = props.idSchema.$id === 'root';
const isRootLevel = props.fieldPathId?.$id === 'root';
return isRootLevel
? <RootObjectFieldTemplate {...props} />
: <ObjectFieldTemplate {...props} />;
@ -75,6 +75,8 @@ export const PromptConfigForm: React.FC<PromptConfigFormProps> = ({
return {
ArrayFieldTemplate,
ArrayFieldItemTemplate,
FieldTemplate,
ObjectFieldTemplate: rootObjectFieldTemplate,
};
@ -145,7 +147,7 @@ export const PromptConfigForm: React.FC<PromptConfigFormProps> = ({
widgets={widgets}
fields={fields}
showErrorList={false}
liveValidate
liveValidate='onChange'
noHtml5Validate
>
<div />

View file

@ -0,0 +1,106 @@
import DragHandleIcon from '@mui/icons-material/DragHandle';
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import { Box, IconButton } from '@mui/material';
import { ArrayFieldItemTemplateProps, FormContextType, getTemplate, getUiOptions, RJSFSchema } from '@rjsf/utils';
import React, { useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ArrayItemProvider } from '../context/ArrayItemContext';
/**
* Custom Array Field Item Template with collapse and drag-and-drop support
* In RJSF 6.x, this template is called by ArrayField to render each item
*/
export function ArrayFieldItemTemplate<T = unknown, S extends RJSFSchema = RJSFSchema, F extends FormContextType = FormContextType>(
props: ArrayFieldItemTemplateProps<T, S, F>,
): React.ReactElement {
const { children, index, hasToolbar, buttonsProps, registry, uiSchema } = props;
const { t } = useTranslation('agent');
const [expanded, setExpanded] = useState(false);
const handleToggleExpanded = useCallback(() => {
setExpanded((previous) => !previous);
}, []);
// Get the ArrayFieldItemButtonsTemplate to render buttons
const uiOptions = getUiOptions<T, S, F>(uiSchema);
const ArrayFieldItemButtonsTemplate = getTemplate<'ArrayFieldItemButtonsTemplate', T, S, F>(
'ArrayFieldItemButtonsTemplate',
registry,
uiOptions,
);
return (
<ArrayItemProvider isInArrayItem arrayItemCollapsible>
<Box
sx={{
border: '1px solid',
borderColor: 'divider',
borderRadius: 1,
mb: 1,
overflow: 'hidden',
}}
>
{/* Header with controls */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
p: 1,
bgcolor: 'background.default',
borderBottom: expanded ? '1px solid' : 'none',
borderColor: 'divider',
}}
>
{/* Drag handle */}
<Box
sx={{
cursor: 'grab',
display: 'flex',
alignItems: 'center',
color: 'text.secondary',
'&:active': { cursor: 'grabbing' },
}}
>
<DragHandleIcon fontSize='small' />
</Box>
{/* Item title */}
<Box sx={{ flex: 1, display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Box
component='span'
sx={{
fontSize: '0.875rem',
fontWeight: 500,
color: 'text.primary',
}}
>
{t('PromptConfig.ItemIndex', { index: index + 1 })}
</Box>
</Box>
{/* Expand/collapse button */}
<IconButton
size='small'
onClick={handleToggleExpanded}
title={expanded ? t('PromptConfig.Collapse') : t('PromptConfig.Expand')}
sx={{ color: 'text.secondary' }}
>
{expanded ? <ExpandLessIcon fontSize='small' /> : <ExpandMoreIcon fontSize='small' />}
</IconButton>
{/* Action buttons (remove, move up/down, etc.) */}
{hasToolbar && <ArrayFieldItemButtonsTemplate {...buttonsProps} />}
</Box>
{/* Content */}
{expanded && (
<Box sx={{ p: 2 }}>
{children}
</Box>
)}
</Box>
</ArrayItemProvider>
);
}

View file

@ -1,40 +1,19 @@
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
import { DndContext, DragEndEvent, PointerSensor, useSensor, useSensors } from '@dnd-kit/core';
import { restrictToVerticalAxis } from '@dnd-kit/modifiers';
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
import { Box, Typography } from '@mui/material';
import { ArrayFieldTemplateProps } from '@rjsf/utils';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { ArrayAddButton, ArrayContainer, ArrayHeader, ArrayItemCount, EmptyState, HelpTooltip, SortableArrayItem, StyledFieldLabel } from '../components';
import { ArrayAddButton, ArrayContainer, ArrayHeader, ArrayItemCount, EmptyState, HelpTooltip, StyledFieldLabel } from '../components';
/**
* Enhanced Array Field Template with drag-and-drop functionality
* Enhanced Array Field Template
* In RJSF 6.x, items are pre-rendered ReactElements, so we just display them
* The drag-and-drop and collapse logic is handled in ArrayFieldItemTemplate
*/
export const ArrayFieldTemplate: React.FC<ArrayFieldTemplateProps> = (props) => {
const { items, onAddClick, canAdd, title, schema, formData } = props;
const { items, onAddClick, canAdd, title, schema } = props;
const { t } = useTranslation('agent');
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 8 },
}),
);
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const activeIndex = items.findIndex((item) => item.key === active.id);
const overIndex = items.findIndex((item) => item.key === over.id);
if (activeIndex !== overIndex && activeIndex !== -1 && overIndex !== -1) {
const activeItem = items[activeIndex];
activeItem.buttonsProps.onReorderClick(activeIndex, overIndex)();
}
};
const description = schema.description;
const itemIds = items.map((item) => item.key);
const isItemsCollapsible = true;
return (
<ArrayContainer>
@ -64,26 +43,9 @@ export const ArrayFieldTemplate: React.FC<ArrayFieldTemplateProps> = (props) =>
</EmptyState>
)
: (
<DndContext
sensors={sensors}
modifiers={[restrictToVerticalAxis]}
onDragEnd={handleDragEnd}
>
<SortableContext items={itemIds} strategy={verticalListSortingStrategy}>
{items.map((item, index) => {
const itemData = Array.isArray(formData) ? formData[index] : undefined;
return (
<SortableArrayItem
key={item.key}
item={item}
index={index}
isCollapsible={isItemsCollapsible}
itemData={itemData}
/>
);
})}
</SortableContext>
</DndContext>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{items}
</Box>
)}
{canAdd && (

View file

@ -9,7 +9,6 @@ export const RootObjectFieldTemplate: React.FC<ObjectFieldTemplateProps> = (prop
const { properties, schema } = props;
const [activeTab, setActiveTab] = useState(0);
const { t } = useTranslation('agent');
const { formFieldsToScrollTo } = useAgentChatStore(
useShallow((state) => ({
formFieldsToScrollTo: state.formFieldsToScrollTo,

View file

@ -1,3 +1,4 @@
export { ArrayFieldItemTemplate } from './ArrayFieldItemTemplate';
export { ArrayFieldTemplate } from './ArrayFieldTemplate';
export { FieldTemplate } from './FieldTemplate';
export { ObjectFieldTemplate } from './ObjectFieldTemplate';

View file

@ -1,8 +1,7 @@
import { Helmet } from '@dr.pogodin/react-helmet';
import { styled, Theme } from '@mui/material/styles';
import { styled } from '@mui/material/styles';
import { lazy } from 'react';
import { useTranslation } from 'react-i18next';
import is, { isNot } from 'typescript-styled-is';
import { Route, Switch } from 'wouter';
import { PageType } from '@/constants/pageTypes';
@ -42,20 +41,25 @@ const Root = styled('div')`
}
`;
const ContentRoot = styled('div')<{ $sidebar: boolean }>`
const ContentRoot = styled('div')<{ $sidebar: boolean }>(
({ theme, $sidebar }) => `
flex: 1;
display: flex;
flex-direction: column;
${is('$sidebar')`
width: calc(100% - ${({ theme }: { theme: Theme }) => theme.sidebar.width}px);
max-width: calc(100% - ${({ theme }: { theme: Theme }) => theme.sidebar.width}px);
`}
${isNot('$sidebar')`
width: 100%;
`}
height: 100%;
`;
${
$sidebar
? `
width: calc(100% - ${theme.sidebar.width}px);
max-width: calc(100% - ${theme.sidebar.width}px);
`
: `
width: 100%;
`
}
`,
);
export default function Main(): React.JSX.Element {
const { t } = useTranslation();

View file

@ -14,6 +14,7 @@ import { WindowNames } from '@services/windows/WindowProperties';
import { browserViewMetaData } from './common/browserViewMetaData';
import './view';
import { syncTidgiStateWhenWikiLoads } from './appState';
import { consoleLogToLogFile } from './fixer/consoleLogToLogFile';
import { fixAlertConfirm } from './fixer/fixAlertConfirm';
declare global {
@ -26,6 +27,9 @@ declare global {
switch (browserViewMetaData.windowName) {
case WindowNames.main: {
// Enable console logging to file for main window
void consoleLogToLogFile('TidGi');
/**
* automatically reload page/wiki when wifi/network is re-connected to a different one, which may cause local ip changed. Or wifi status changed when wiki startup, causing wiki not loaded properly.
* @url https://www.electronjs.org/docs/latest/tutorial/online-offline-events

View file

@ -23,6 +23,7 @@ import { useThemeObservable } from '@services/theme/hooks';
import { initRendererI18N } from './services/libs/i18n/renderer';
import 'electron-ipc-cat/fixContextIsolation';
import { useHashLocation } from 'wouter/use-hash-location';
import { ErrorBoundary } from './components/ErrorBoundary';
import { RootStyle } from './components/RootStyle';
import { initTestKeyboardShortcutFallback } from './helpers/testKeyboardShortcuts';
import { Pages } from './windows';
@ -33,22 +34,24 @@ function App(): JSX.Element {
return (
<StrictMode>
<ThemeProvider theme={theme?.shouldUseDarkColors === true ? darkTheme : lightTheme}>
<StyledEngineProvider injectFirst>
<LocalizationProvider dateAdapter={AdapterDateFns}>
<CssBaseline />
<Suspense fallback={<div />}>
<I18nextProvider i18n={i18next}>
<RootStyle>
<Router hook={useHashLocation}>
<Pages />
</Router>
</RootStyle>
</I18nextProvider>
</Suspense>
</LocalizationProvider>
</StyledEngineProvider>
</ThemeProvider>
<ErrorBoundary>
<ThemeProvider theme={theme?.shouldUseDarkColors === true ? darkTheme : lightTheme}>
<StyledEngineProvider injectFirst>
<LocalizationProvider dateAdapter={AdapterDateFns}>
<CssBaseline />
<Suspense fallback={<div />}>
<I18nextProvider i18n={i18next}>
<RootStyle>
<Router hook={useHashLocation}>
<Pages />
</Router>
</RootStyle>
</I18nextProvider>
</Suspense>
</LocalizationProvider>
</StyledEngineProvider>
</ThemeProvider>
</ErrorBoundary>
</StrictMode>
);
}

View file

@ -55,6 +55,12 @@ const labeledLoggers = new Map<string, winston.Logger>();
* @returns A winston logger instance for the specified label
*/
export function getLoggerForLabel(label: string): winston.Logger {
// Special case: if label is 'TidGi', return the main logger to avoid file write conflicts
// This allows main window console logs to merge into the same TidGi-*.log file
if (label === 'TidGi') {
return logger;
}
const existingLogger = labeledLoggers.get(label);
if (existingLogger) {
return existingLogger;

View file

@ -4,12 +4,10 @@ import type { IWikiWorkspace, IWorkspace } from '@services/workspaces/interface'
import { backOff } from 'exponential-backoff';
import fs from 'fs';
import path from 'path';
import type { FileInfo } from 'tiddlywiki';
import type { IFileInfo } from 'tiddlywiki';
import type { Tiddler, Wiki } from 'tiddlywiki';
import { isFileLockError } from './utilities';
export type IFileSystemAdaptorCallback = (error: Error | null | string, fileInfo?: FileInfo | null) => void;
/**
* Base filesystem adaptor that handles tiddler save/delete operations and sub-wiki routing.
* This class can be used standalone or extended for additional functionality like file watching.
@ -110,7 +108,7 @@ export class FileSystemAdaptor {
return true;
}
getTiddlerInfo(tiddler: Tiddler): FileInfo | undefined {
getTiddlerInfo(tiddler: Tiddler): IFileInfo | undefined {
const title = tiddler.fields.title;
return this.boot.files[title];
}
@ -119,7 +117,7 @@ export class FileSystemAdaptor {
* Main routing logic: determine where a tiddler should be saved based on its tags.
* For draft tiddlers, check the original tiddler's tags.
*/
async getTiddlerFileInfo(tiddler: Tiddler): Promise<FileInfo | null> {
async getTiddlerFileInfo(tiddler: Tiddler): Promise<IFileInfo | null> {
if (!this.boot.wikiTiddlersPath) {
throw new Error('filesystem adaptor requires a valid wiki folder');
}
@ -165,7 +163,7 @@ export class FileSystemAdaptor {
* Generate file info for sub-wiki directory
* Handles symlinks correctly across platforms (Windows junctions and Linux symlinks)
*/
protected generateSubWikiFileInfo(tiddler: Tiddler, subWiki: IWikiWorkspace, fileInfo: FileInfo | undefined): FileInfo {
protected generateSubWikiFileInfo(tiddler: Tiddler, subWiki: IWikiWorkspace, fileInfo: IFileInfo | undefined): IFileInfo {
let targetDirectory = subWiki.wikiFolderLocation;
// Resolve symlinks to ensure consistent path handling across platforms
@ -186,14 +184,14 @@ export class FileSystemAdaptor {
pathFilters: undefined,
extFilters: this.extensionFilters,
wiki: this.wiki,
fileInfo: fileInfo ? { ...fileInfo, overwrite: true } : { overwrite: true } as FileInfo,
fileInfo: fileInfo ? { ...fileInfo, overwrite: true } : { overwrite: true } as IFileInfo,
});
}
/**
* Generate file info using default FileSystemPaths logic
*/
protected generateDefaultFileInfo(tiddler: Tiddler, fileInfo: FileInfo | undefined): FileInfo {
protected generateDefaultFileInfo(tiddler: Tiddler, fileInfo: IFileInfo | undefined): IFileInfo {
let pathFilters: string[] | undefined;
if (this.wiki.tiddlerExists('$:/config/FileSystemPaths')) {
@ -206,7 +204,7 @@ export class FileSystemAdaptor {
pathFilters,
extFilters: this.extensionFilters,
wiki: this.wiki,
fileInfo: fileInfo ? { ...fileInfo, overwrite: true } : { overwrite: true } as FileInfo,
fileInfo: fileInfo ? { ...fileInfo, overwrite: true } : { overwrite: true } as IFileInfo,
});
}
@ -214,7 +212,11 @@ export class FileSystemAdaptor {
* Save a tiddler to the filesystem
* Can be used with callback (legacy) or as async/await
*/
async saveTiddler(tiddler: Tiddler, callback?: IFileSystemAdaptorCallback, _options?: { tiddlerInfo?: Record<string, unknown> }): Promise<void> {
async saveTiddler(
tiddler: Tiddler,
callback?: (error: Error | null | string, adaptorInfo?: IFileInfo | null, revision?: string) => void,
_options?: { tiddlerInfo?: Record<string, unknown> },
): Promise<void> {
try {
const fileInfo = await this.getTiddlerFileInfo(tiddler);
@ -240,7 +242,7 @@ export class FileSystemAdaptor {
bootInfo: savedFileInfo, // New file info to be kept
title: tiddler.fields.title,
};
$tw.utils.cleanupTiddlerFiles(cleanupOptions, (cleanupError: Error | null, _cleanedFileInfo?: FileInfo) => {
$tw.utils.cleanupTiddlerFiles(cleanupOptions, (cleanupError: Error | null, _cleanedFileInfo?: IFileInfo) => {
if (cleanupError) {
reject(cleanupError);
return;
@ -260,7 +262,10 @@ export class FileSystemAdaptor {
/**
* Load a tiddler - not needed as all tiddlers are loaded during boot
*/
loadTiddler(_title: string, callback: IFileSystemAdaptorCallback): void {
loadTiddler(
_title: string,
callback: (error: Error | null | string, tiddlerFields?: Record<string, unknown> | null) => void,
): void {
callback(null, null);
}
@ -268,7 +273,11 @@ export class FileSystemAdaptor {
* Delete a tiddler from the filesystem
* Can be used with callback (legacy) or as async/await
*/
async deleteTiddler(title: string, callback?: IFileSystemAdaptorCallback, _options?: unknown): Promise<void> {
async deleteTiddler(
title: string,
callback?: (error: Error | null | string, adaptorInfo?: IFileInfo | null) => void,
_options?: unknown,
): Promise<void> {
const fileInfo = this.boot.files[title];
if (!fileInfo) {
@ -278,7 +287,7 @@ export class FileSystemAdaptor {
try {
await new Promise<void>((resolve, reject) => {
$tw.utils.deleteTiddlerFile(fileInfo, (error: Error | null, deletedFileInfo?: FileInfo) => {
$tw.utils.deleteTiddlerFile(fileInfo, (error: Error | null, deletedFileInfo?: IFileInfo) => {
if (error) {
const errorCode = (error as NodeJS.ErrnoException).code;
const errorSyscall = (error as NodeJS.ErrnoException).syscall;
@ -339,9 +348,9 @@ export class FileSystemAdaptor {
*/
protected async saveTiddlerWithRetry(
tiddler: Tiddler,
fileInfo: FileInfo,
fileInfo: IFileInfo,
options: { maxRetries?: number; initialDelay?: number; maxDelay?: number } = {},
): Promise<FileInfo> {
): Promise<IFileInfo> {
const maxRetries = options.maxRetries ?? 10;
const initialDelay = options.initialDelay ?? 50;
const maxDelay = options.maxDelay ?? 2000;
@ -349,8 +358,8 @@ export class FileSystemAdaptor {
try {
return await backOff(
async () => {
return await new Promise<FileInfo>((resolve, reject) => {
$tw.utils.saveTiddlerToFile(tiddler, fileInfo, (saveError: Error | null, savedFileInfo?: FileInfo) => {
return await new Promise<IFileInfo>((resolve, reject) => {
$tw.utils.saveTiddlerToFile(tiddler, fileInfo, (saveError: Error | null, savedFileInfo?: IFileInfo) => {
if (saveError) {
reject(saveError);
return;

View file

@ -1,8 +1,8 @@
import type nsfw from 'nsfw';
import path from 'path';
import type { FileInfo } from 'tiddlywiki';
import type { IFileInfo } from 'tiddlywiki';
export type IBootFilesIndexItemWithTitle = FileInfo & { tiddlerTitle: string };
export type IBootFilesIndexItemWithTitle = IFileInfo & { tiddlerTitle: string };
export interface ISubWikiInfo {
id: string;

View file

@ -2,10 +2,9 @@ import { git, workspace } from '@services/wiki/wikiWorker/services';
import fs from 'fs';
import nsfw from 'nsfw';
import path from 'path';
import type { Tiddler, Wiki } from 'tiddlywiki';
import { FileSystemAdaptor, type IFileSystemAdaptorCallback } from './FileSystemAdaptor';
import type { IFileInfo, Tiddler, Wiki } from 'tiddlywiki';
import { FileSystemAdaptor } from './FileSystemAdaptor';
import { type IBootFilesIndexItemWithTitle, InverseFilesIndex } from './InverseFilesIndex';
import { getActionName } from './utilities';
/**
* Delay before actually processing file deletion.
@ -99,18 +98,40 @@ export class WatchFileSystemAdaptor extends FileSystemAdaptor {
* Save a tiddler to the filesystem (with file watching support)
* Can be used with callback (legacy) or as async/await
*/
override async saveTiddler(tiddler: Tiddler, callback?: IFileSystemAdaptorCallback, options?: { tiddlerInfo?: Record<string, unknown> }): Promise<void> {
override async saveTiddler(
tiddler: Tiddler,
callback?: (error: Error | null | string, adaptorInfo?: IFileInfo | null, revision?: string) => void,
options?: { tiddlerInfo?: Record<string, unknown> },
): Promise<void> {
try {
// Get existing file info (if tiddler already exists on disk)
const oldFileInfo = this.boot.files[tiddler.fields.title];
// For new tiddlers, pre-calculate the file path and exclude it to prevent echo
// Must exclude both the main file and its .meta file to prevent watch-fs from detecting our own save operations
// This is critical for plugin JSON files which always have a separate .meta file
let excludedNewFilePath: string | undefined;
if (!oldFileInfo) {
try {
const newFileInfo = await this.getTiddlerFileInfo(tiddler);
if (newFileInfo?.filepath) {
this.excludeFile(newFileInfo.filepath);
// Also exclude the .meta file if it exists
const metaFilePath = `${newFileInfo.filepath}.meta`;
this.excludeFile(metaFilePath);
excludedNewFilePath = newFileInfo.filepath;
}
} catch (error) {
this.logger.alert(`WatchFileSystemAdaptor Failed to pre-calculate file path for new tiddler: ${tiddler.fields.title}`, error);
}
}
// Exclude old file path before save (if it exists)
if (oldFileInfo) {
this.excludeFile(oldFileInfo.filepath);
this.logger.log(`[WATCH_FS_SAVE] Excluded existing file: ${oldFileInfo.filepath}`);
} else {
// For new tiddlers, we can't pre-exclude them since we don't know the path yet
this.logger.log(`[WATCH_FS_NEW_TIDDLER] Saving new tiddler: ${tiddler.fields.title}`);
// Also exclude the .meta file if it exists
const metaFilePath = `${oldFileInfo.filepath}.meta`;
this.excludeFile(metaFilePath);
}
// Call parent's saveTiddler to handle the actual save (including cleanup of old files)
@ -130,11 +151,20 @@ export class WatchFileSystemAdaptor extends FileSystemAdaptor {
// Schedule re-inclusion after delay to avoid echo
this.scheduleFileInclusion(finalFileInfo.filepath);
// Also re-include the .meta file
this.scheduleFileInclusion(`${finalFileInfo.filepath}.meta`);
// For edge case, rarely if we wrongly pre-excluded a new file path and it's different from the final path that tw decided to use, re-include it to revoke the influence
if (excludedNewFilePath && excludedNewFilePath !== finalFileInfo.filepath) {
this.scheduleFileInclusion(excludedNewFilePath);
this.scheduleFileInclusion(`${excludedNewFilePath}.meta`);
}
// If old file path was different and we excluded it, re-include it
// The old file should be deleted by now via cleanupTiddlerFiles
if (oldFileInfo && oldFileInfo.filepath !== finalFileInfo.filepath) {
this.scheduleFileInclusion(oldFileInfo.filepath);
this.scheduleFileInclusion(`${oldFileInfo.filepath}.meta`);
}
} catch (error) {
const errorObject = error instanceof Error ? error : new Error(typeof error === 'string' ? error : 'Unknown error');
@ -147,7 +177,11 @@ export class WatchFileSystemAdaptor extends FileSystemAdaptor {
* Delete a tiddler from the filesystem (with file watching support)
* Can be used with callback (legacy) or as async/await
*/
override async deleteTiddler(title: string, callback?: IFileSystemAdaptorCallback, _options?: unknown): Promise<void> {
override async deleteTiddler(
title: string,
callback?: (error: Error | null | string, adaptorInfo?: IFileInfo | null) => void,
_options?: unknown,
): Promise<void> {
const fileInfo = this.boot.files[title];
if (!fileInfo) {
@ -195,11 +229,11 @@ export class WatchFileSystemAdaptor extends FileSystemAdaptor {
try {
const currentWorkspace = await workspace.get(this.workspaceID);
if (currentWorkspace && 'enableFileSystemWatch' in currentWorkspace && !currentWorkspace.enableFileSystemWatch) {
this.logger.log('[WATCH_FS_DISABLED] File system watching is disabled for this workspace');
this.logger.log('WatchFileSystemAdaptor File system watching is disabled for this workspace');
return;
}
} catch (error) {
this.logger.alert('[WATCH_FS_ERROR] Failed to check enableFileSystemWatch setting:', error);
this.logger.alert('WatchFileSystemAdaptor Failed to check enableFileSystemWatch setting:', error);
return;
}
}
@ -225,7 +259,7 @@ export class WatchFileSystemAdaptor extends FileSystemAdaptor {
{
debounceMS: 100,
errorCallback: (error) => {
this.logger.alert('[WATCH_FS_ERROR] NSFW error:', error);
this.logger.alert('WatchFileSystemAdaptor NSFW error:', error);
},
// Start with base excluded paths
// @ts-expect-error - nsfw types are incorrect, it accepts string[] not just [string]
@ -240,7 +274,7 @@ export class WatchFileSystemAdaptor extends FileSystemAdaptor {
// Log stabilization marker for tests
this.logger.log('[test-id-WATCH_FS_STABILIZED] Watcher has stabilized', { level: 'debug' });
} catch (error) {
this.logger.alert('[WATCH_FS_ERROR] Failed to initialize file watching:', error);
this.logger.alert('WatchFileSystemAdaptor Failed to initialize file watching:', error);
}
}
@ -256,7 +290,7 @@ export class WatchFileSystemAdaptor extends FileSystemAdaptor {
// Get sub-wikis for this main wiki
const subWikis = await workspace.getSubWorkspacesAsList(this.workspaceID);
this.logger.log(`[WATCH_FS_SUBWIKI] Found ${subWikis.length} sub-wikis to watch`);
this.logger.log(`WatchFileSystemAdaptor Found ${subWikis.length} sub-wikis to watch`);
// Create watcher for each sub-wiki
for (const subWiki of subWikis) {
@ -270,7 +304,7 @@ export class WatchFileSystemAdaptor extends FileSystemAdaptor {
// Check if the path exists before trying to watch
if (!fs.existsSync(subWikiPath)) {
this.logger.log(`[WATCH_FS_SUBWIKI] Path does not exist for sub-wiki ${subWiki.name}: ${subWikiPath}`);
this.logger.log(`WatchFileSystemAdaptor Path does not exist for sub-wiki ${subWiki.name}: ${subWikiPath}`);
continue;
}
@ -283,7 +317,7 @@ export class WatchFileSystemAdaptor extends FileSystemAdaptor {
{
debounceMS: 100,
errorCallback: (error) => {
this.logger.alert(`[WATCH_FS_ERROR] NSFW error for sub-wiki ${subWiki.name}:`, error);
this.logger.alert(`WatchFileSystemAdaptor NSFW error for sub-wiki ${subWiki.name}:`, error);
},
},
);
@ -291,13 +325,13 @@ export class WatchFileSystemAdaptor extends FileSystemAdaptor {
await subWikiWatcher.start();
this.inverseFilesIndex.registerSubWiki(subWiki.id, subWikiPath, subWikiWatcher);
this.logger.log(`[WATCH_FS_SUBWIKI] Watching sub-wiki: ${subWiki.name} at ${subWikiPath}`);
this.logger.log(`WatchFileSystemAdaptor Watching sub-wiki: ${subWiki.name} at ${subWikiPath}`);
} catch (error) {
this.logger.alert(`[WATCH_FS_ERROR] Failed to watch sub-wiki ${subWiki.name}:`, error);
this.logger.alert(`WatchFileSystemAdaptor Failed to watch sub-wiki ${subWiki.name}:`, error);
}
}
} catch (error) {
this.logger.alert('[WATCH_FS_ERROR] Failed to initialize sub-wiki watchers:', error);
this.logger.alert('WatchFileSystemAdaptor Failed to initialize sub-wiki watchers:', error);
}
}
@ -322,7 +356,6 @@ export class WatchFileSystemAdaptor extends FileSystemAdaptor {
* @param absoluteFilePath Absolute file path
*/
private excludeFile(absoluteFilePath: string): void {
this.logger.log(`[WATCH_FS_EXCLUDE] Excluding file: ${absoluteFilePath}`);
this.inverseFilesIndex.excludeFile(absoluteFilePath);
}
@ -339,7 +372,6 @@ export class WatchFileSystemAdaptor extends FileSystemAdaptor {
}
const timer = setTimeout(() => {
this.logger.log(`[WATCH_FS_INCLUDE] Including file: ${absoluteFilePath}`);
this.inverseFilesIndex.includeFile(absoluteFilePath);
this.pendingInclusions.delete(absoluteFilePath);
// Notify git service when file is included after being saved
@ -359,7 +391,7 @@ export class WatchFileSystemAdaptor extends FileSystemAdaptor {
if (existingTimer) {
clearTimeout(existingTimer);
this.pendingDeletions.delete(fileAbsolutePath);
this.logger.log(`[WATCH_FS_CANCEL_DELETE] Cancelled pending deletion for: ${fileAbsolutePath}`);
this.logger.log(`WatchFileSystemAdaptor Cancelled pending deletion for: ${fileAbsolutePath}`);
}
}
@ -381,7 +413,6 @@ export class WatchFileSystemAdaptor extends FileSystemAdaptor {
}, FILE_DELETION_DELAY_MS);
this.pendingDeletions.set(fileAbsolutePath, timer);
this.logger.log(`[WATCH_FS_SCHEDULE_DELETE] Scheduled deletion for: ${fileAbsolutePath}`);
}
/**
@ -411,7 +442,7 @@ export class WatchFileSystemAdaptor extends FileSystemAdaptor {
: this.inverseFilesIndex.isMainFileExcluded(fileAbsolutePath);
if (isExcluded) {
this.logger.log(`[WATCH_FS_SKIP_EXCLUDED] Skipping excluded file: ${fileAbsolutePath}`);
this.logger.log(`WatchFileSystemAdaptor Skipping excluded file: ${fileAbsolutePath}`);
continue;
}
@ -428,10 +459,20 @@ export class WatchFileSystemAdaptor extends FileSystemAdaptor {
const fileMimeType = $tw.utils.getFileExtensionInfo(fileExtension)?.type ?? 'text/vnd.tiddlywiki';
const metaFileAbsolutePath = `${fileAbsolutePath}.meta`;
this.logger.log('[WATCH_FS_EVENT]', getActionName(action), fileName, `(directory: ${directory})`);
// Handle different event types
if (action === nsfw.actions.CREATED || action === nsfw.actions.MODIFIED) {
// Skip if it's a directory (nsfw sometimes reports directory changes)
try {
const stats = fs.statSync(fileAbsolutePath);
if (stats.isDirectory()) {
this.logger.log(`WatchFileSystemAdaptor Skipping directory: ${fileAbsolutePath}`);
continue;
}
} catch {
// File might have been deleted already, skip
continue;
}
// Cancel any pending deletion for this file (e.g., git revert scenario)
this.cancelPendingDeletion(fileAbsolutePath);
@ -508,13 +549,9 @@ export class WatchFileSystemAdaptor extends FileSystemAdaptor {
// watchPathBase is wiki/tiddlers, but wikiFolderLocation should be wiki
const wikiFolderLocation = path.dirname(this.watchPathBase);
try {
(git.notifyFileChange as ((path: string, options?: { onlyWhenGitLogOpened?: boolean }) => void))(
wikiFolderLocation,
{ onlyWhenGitLogOpened: true },
);
this.logger.log(`[WATCH_FS_GIT_NOTIFY] Notified git service about file changes in ${wikiFolderLocation}`);
void git.notifyFileChange(wikiFolderLocation, { onlyWhenGitLogOpened: true });
} catch (error) {
this.logger.alert('[WATCH_FS_GIT_NOTIFY_ERROR] Failed to notify git service:', error);
this.logger.alert('WatchFileSystemAdaptor Failed to notify git service:', error);
}
this.gitNotificationTimer = undefined;
}, GIT_NOTIFICATION_DELAY_MS);
@ -547,7 +584,7 @@ export class WatchFileSystemAdaptor extends FileSystemAdaptor {
try {
tiddlersDescriptor = $tw.loadTiddlersFromFile(actualFileToLoad);
} catch (error) {
this.logger.alert('[WATCH_FS_LOAD_ERROR] Failed to load file:', actualFileToLoad, error);
this.logger.alert('WatchFileSystemAdaptor Failed to load file:', actualFileToLoad, error);
return;
}
@ -573,7 +610,7 @@ export class WatchFileSystemAdaptor extends FileSystemAdaptor {
// not wrapped in a .fields property
const tiddlerTitle = tiddler?.title;
if (!tiddlerTitle) {
this.logger.alert(`[WATCH_FS_ERROR] Tiddler has no title. Tiddler object: ${JSON.stringify(tiddler)}`);
this.logger.alert(`WatchFileSystemAdaptor Tiddler has no title. Tiddler object: ${JSON.stringify(tiddler)}`);
continue;
}
@ -587,7 +624,8 @@ export class WatchFileSystemAdaptor extends FileSystemAdaptor {
} as IBootFilesIndexItemWithTitle);
// Add tiddler to wiki (this will update if it exists or add if new)
$tw.syncadaptor!.wiki.addTiddler(tiddler);
const syncAdaptor = $tw.syncadaptor as { wiki: Wiki } | undefined | null;
syncAdaptor?.wiki.addTiddler(tiddler);
// Log appropriate event
if (isNewFile) {
@ -625,7 +663,8 @@ export class WatchFileSystemAdaptor extends FileSystemAdaptor {
}
// Check if tiddler exists in wiki before trying to delete
if (!$tw.syncadaptor!.wiki.tiddlerExists(tiddlerTitle)) {
const syncAdaptor = $tw.syncadaptor as { wiki: Wiki } | undefined | null;
if (!syncAdaptor?.wiki.tiddlerExists(tiddlerTitle)) {
// Tiddler doesn't exist in wiki, nothing to delete
return;
}
@ -634,7 +673,7 @@ export class WatchFileSystemAdaptor extends FileSystemAdaptor {
this.removeTiddlerFileInfo(tiddlerTitle);
// Delete the tiddler from wiki to trigger change event
$tw.syncadaptor!.wiki.deleteTiddler(tiddlerTitle);
syncAdaptor?.wiki.deleteTiddler(tiddlerTitle);
this.logger.log(`[test-id-WATCH_FS_TIDDLER_DELETED] ${tiddlerTitle}`, { level: 'debug' });
// Delete system tiddler empty file if exists
@ -677,15 +716,15 @@ export class WatchFileSystemAdaptor extends FileSystemAdaptor {
this.pendingInclusions.clear();
if (this.watcher) {
this.logger.log('[WATCH_FS_CLEANUP] Closing filesystem watcher');
this.logger.log('WatchFileSystemAdaptor Closing filesystem watcher');
await this.watcher.stop();
this.watcher = undefined;
this.logger.log('[WATCH_FS_CLEANUP] Filesystem watcher closed');
this.logger.log('WatchFileSystemAdaptor Filesystem watcher closed');
}
// Close all sub-wiki watchers
for (const subWiki of this.inverseFilesIndex.getSubWikis()) {
this.logger.log(`[WATCH_FS_CLEANUP] Closing sub-wiki watcher: ${subWiki.id}`);
this.logger.log(`WatchFileSystemAdaptor Closing sub-wiki watcher: ${subWiki.id}`);
await subWiki.watcher.stop();
this.inverseFilesIndex.unregisterSubWiki(subWiki.id);
}

View file

@ -1,28 +1,3 @@
import nsfw from 'nsfw';
/**
* Get human-readable action name from nsfw action code
*/
export function getActionName(action: number): string {
// eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison
if (action === nsfw.actions.CREATED) {
return 'add';
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison
if (action === nsfw.actions.DELETED) {
return 'unlink';
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison
if (action === nsfw.actions.MODIFIED) {
return 'change';
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison
if (action === nsfw.actions.RENAMED) {
return 'rename';
}
return 'unknown';
}
/**
* Check if error is a file lock error that should be retried
*/

View file

@ -14,7 +14,6 @@ import path from 'path';
import { Observable } from 'rxjs';
import type { IWikiWorkspace } from '@services/workspaces/interface';
import type { SyncAdaptor } from 'tiddlywiki';
import { IZxWorkerMessage, ZxWorkerControlActions } from '../interface';
import { executeScriptInTWContext, executeScriptInZxScriptContext, extractTWContextScripts, type IVariableContextList } from '../plugin/zxPlugin';
import { wikiOperationsInWikiWorker } from '../wikiOperations/executor/wikiOperationInServer';
@ -101,7 +100,7 @@ async function beforeExit(): Promise<void> {
// Cleanup watch-filesystem adaptor
const wikiInstance = getWikiInstance();
// Call our custom cleanup method if it exists `src/services/wiki/plugin/watchFileSystemAdaptor/watch-filesystem-adaptor.ts`
const syncAdaptor = wikiInstance?.syncadaptor as SyncAdaptor & { cleanup?: () => Promise<void> };
const syncAdaptor = wikiInstance?.syncadaptor as { cleanup?: () => Promise<void> } | undefined;
if (syncAdaptor?.cleanup) {
await syncAdaptor.cleanup();
}

View file

@ -8,6 +8,7 @@ import { defaultServerIP } from '@/constants/urls';
import { DARK_LIGHT_CHANGE_ACTIONS_TAG } from '@services/theme/interface';
import intercept from 'intercept-stdout';
import { nanoid } from 'nanoid';
import type { Server } from 'node:http';
import inspector from 'node:inspector';
import path from 'path';
import { Observable } from 'rxjs';
@ -180,8 +181,8 @@ export function startNodeJSWiki({
: [homePath, '--version'];
wikiInstance.boot.argv = [...fullBootArgv];
wikiInstance.hooks.addHook('th-server-command-post-start', function(_listenCommand, server) {
server.on('error', function(error: Error) {
wikiInstance.hooks.addHook('th-server-command-post-start', function(_server: unknown, nodeServer: Server) {
nodeServer.on('error', function(error: Error) {
observer.next({ type: 'control', actions: WikiControlActions.error, message: error.message, argv: fullBootArgv });
});
// Similar to how updateActiveWikiTheme calls WikiChannel.invokeActionsByTag
@ -189,7 +190,7 @@ export function startNodeJSWiki({
wikiInstance.rootWidget.invokeActionsByTag(DARK_LIGHT_CHANGE_ACTIONS_TAG, new Event('TidGi-invokeActionByTag') as unknown as IWidgetEvent, {
'dark-mode': shouldUseDarkColors ? 'yes' : 'no',
});
server.on('listening', function() {
nodeServer.on('listening', function() {
observer.next({
type: 'control',
actions: WikiControlActions.listening,

View file

@ -207,7 +207,7 @@ export class Window implements IWindowService {
...(hideTitleBar && process.platform !== 'darwin' ? { titleBarOverlay: true } : {}),
alwaysOnTop: windowName === WindowNames.tidgiMiniWindow ? tidgiMiniWindowAlwaysOnTop : alwaysOnTop,
webPreferences: {
devTools: !isTest,
devTools: true, // Always enable devTools, even in test mode for debugging
nodeIntegration: false,
webSecurity: false,
allowRunningInsecureContent: true,

View file

@ -290,7 +290,7 @@ export default function EditWorkspace(): React.JSX.Element {
// Show error notification
void window.service.notification.show({
title: t('EditWorkspace.MoveWorkspaceFailed'),
body: t('EditWorkspace.MoveWorkspaceFailedMessage', { name: workspaceName, error: errorMessage }),
body: t('EditWorkspace.MoveWorkspaceFailedMessage', { name: workspace.name, error: errorMessage }),
});
}
}