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']` 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. 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 ## An unhandled rejection has occurred inside Forge about node-abi
Solution: Update `@electron/rebuild` to latest version: 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']" When I type "" in "agent name input" element with selector "[data-testid='agent-name-input-field']"
# Advance to step 2 (Edit Prompt) # Advance to step 2 (Edit Prompt)
When I click on a "next button" element with selector "[data-testid='next-button']" When I click on a "next button" element with selector "[data-testid='next-button']"
# Step 4: Verify second step content (Edit Prompt) # 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 "*:has-text('')" And I should see a "edit prompt title" element with selector "h6:has-text('')"
# Step 4.1: Wait for PromptConfigForm to load # Step 4.1: Wait for PromptConfigForm to load
# Verify the PromptConfigForm is present with our new test id # 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']" 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]) 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]) | | [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])" 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])" 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 for "form content save to backend"
And I wait for 0.2 seconds
# Step 5: Advance to step 3 (Immediate Use) # Step 5: Advance to step 3 (Immediate Use)
When I click on a "next button" element with selector "[data-testid='next-button']" 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) # 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) { Then('I should see a(n) {string} element with selector {string}', async function(this: ApplicationWorld, elementComment: string, selector: string) {
const currentWindow = this.currentWindow; const currentWindow = this.currentWindow;
try { try {
await currentWindow?.waitForSelector(selector, { timeout: 10000 }); await currentWindow?.waitForSelector(selector, { timeout: 10000 });
const isVisible = await currentWindow?.isVisible(selector); const isVisible = await currentWindow?.isVisible(selector);

View file

@ -2,7 +2,7 @@
"name": "tidgi", "name": "tidgi",
"productName": "TidGi", "productName": "TidGi",
"description": "Customizable personal knowledge-base with Github as unlimited storage and blogging platform.", "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", "license": "MPL 2.0",
"packageManager": "pnpm@10.18.2", "packageManager": "pnpm@10.18.2",
"scripts": { "scripts": {
@ -33,10 +33,10 @@
"author": "Lin Onetwo <linonetwo012@gmail.com>, Quang Lam <quang.lam2807@gmail.com>", "author": "Lin Onetwo <linonetwo012@gmail.com>, Quang Lam <quang.lam2807@gmail.com>",
"main": ".vite/build/main.js", "main": ".vite/build/main.js",
"dependencies": { "dependencies": {
"@ai-sdk/anthropic": "^2.0.35", "@ai-sdk/anthropic": "^2.0.45",
"@ai-sdk/deepseek": "^1.0.23", "@ai-sdk/deepseek": "^1.0.29",
"@ai-sdk/openai": "^2.0.53", "@ai-sdk/openai": "^2.0.71",
"@ai-sdk/openai-compatible": "^1.0.22", "@ai-sdk/openai-compatible": "^1.0.27",
"@algolia/autocomplete-js": "^1.19.4", "@algolia/autocomplete-js": "^1.19.4",
"@algolia/autocomplete-theme-classic": "^1.19.4", "@algolia/autocomplete-theme-classic": "^1.19.4",
"@dnd-kit/core": "6.3.1", "@dnd-kit/core": "6.3.1",
@ -46,75 +46,75 @@
"@dr.pogodin/react-helmet": "^3.0.4", "@dr.pogodin/react-helmet": "^3.0.4",
"@fontsource/roboto": "^5.2.8", "@fontsource/roboto": "^5.2.8",
"@monaco-editor/react": "^4.7.0", "@monaco-editor/react": "^4.7.0",
"@mui/icons-material": "^7.3.4", "@mui/icons-material": "^7.3.5",
"@mui/material": "^7.3.4", "@mui/material": "^7.3.5",
"@mui/system": "^7.3.3", "@mui/system": "^7.3.5",
"@mui/types": "^7.4.7", "@mui/types": "^7.4.8",
"@mui/x-date-pickers": "^8.14.1", "@mui/x-date-pickers": "^8.19.0",
"@rjsf/core": "6.0.0-beta.8", "@rjsf/core": "6.1.2",
"@rjsf/mui": "6.0.0-beta.10", "@rjsf/mui": "6.1.2",
"@rjsf/utils": "6.0.0-beta.10", "@rjsf/utils": "6.1.2",
"@rjsf/validator-ajv8": "6.0.0-beta.8", "@rjsf/validator-ajv8": "6.1.2",
"@tomplum/react-git-log": "^3.5.0", "@tomplum/react-git-log": "^3.5.0",
"ai": "^5.0.76", "ai": "^5.0.98",
"ansi-to-html": "^0.7.2", "ansi-to-html": "^0.7.2",
"app-path": "^4.0.0", "app-path": "^4.0.0",
"beautiful-react-hooks": "5.0.3", "beautiful-react-hooks": "5.0.3",
"best-effort-json-parser": "1.2.1", "best-effort-json-parser": "1.2.1",
"better-sqlite3": "^12.4.1", "better-sqlite3": "^12.4.5",
"bluebird": "3.7.2", "bluebird": "3.7.2",
"date-fns": "3.6.0", "date-fns": "3.6.0",
"default-gateway": "6.0.3", "default-gateway": "6.0.3",
"dugite": "3.0.0-rc12", "dugite": "3.0.0",
"electron-dl": "^4.0.0", "electron-dl": "^4.0.0",
"electron-ipc-cat": "2.2.3", "electron-ipc-cat": "2.2.3",
"electron-settings": "5.0.0", "electron-settings": "5.0.0",
"electron-unhandled": "4.0.1", "electron-unhandled": "4.0.1",
"electron-window-state": "5.0.3", "electron-window-state": "5.0.3",
"espree": "^10.4.0", "espree": "^11.0.0",
"exponential-backoff": "^3.1.3", "exponential-backoff": "^3.1.3",
"fs-extra": "11.3.2", "fs-extra": "11.3.2",
"git-sync-js": "^2.2.1", "git-sync-js": "^2.3.0",
"graphql-hooks": "8.2.0", "graphql-hooks": "8.2.0",
"html-minifier-terser": "^7.2.0", "html-minifier-terser": "^7.2.0",
"i18next": "25.6.0", "i18next": "25.6.3",
"i18next-electron-fs-backend": "3.0.3", "i18next-electron-fs-backend": "3.0.3",
"i18next-fs-backend": "2.6.0", "i18next-fs-backend": "2.6.1",
"immer": "^10.1.3", "immer": "^10.2.0",
"intercept-stdout": "0.1.2", "intercept-stdout": "0.1.2",
"inversify": "7.10.3", "inversify": "7.10.4",
"ipaddr.js": "2.2.0", "ipaddr.js": "2.2.0",
"jimp": "1.6.0", "jimp": "1.6.0",
"json5": "^2.2.3", "json5": "^2.2.3",
"lodash": "4.17.21", "lodash": "4.17.21",
"material-ui-popup-state": "^5.3.6", "material-ui-popup-state": "^5.3.6",
"menubar": "9.5.2", "menubar": "9.5.2",
"monaco-editor": "^0.54.0", "monaco-editor": "^0.55.1",
"nanoid": "^5.1.6", "nanoid": "^5.1.6",
"new-github-issue-url": "^1.1.0", "new-github-issue-url": "^1.1.0",
"node-fetch": "3.3.2", "node-fetch": "3.3.2",
"nsfw": "^2.2.5", "nsfw": "^2.2.5",
"oidc-client-ts": "^3.3.0", "oidc-client-ts": "^3.4.1",
"ollama-ai-provider-v2": "^1.5.1", "ollama-ai-provider-v2": "^1.5.5",
"react": "19.2.0", "react": "19.2.0",
"react-dom": "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-masonry-css": "^1.0.16",
"react-window": "^2.2.1", "react-window": "^2.2.3",
"reflect-metadata": "0.2.2", "reflect-metadata": "0.2.2",
"registry-js": "1.16.1", "registry-js": "1.16.1",
"rotating-file-stream": "^3.2.7", "rotating-file-stream": "^3.2.7",
"rxjs": "7.8.2", "rxjs": "7.8.2",
"semver": "7.7.3", "semver": "7.7.3",
"serialize-error": "^12.0.0", "serialize-error": "^12.0.0",
"simplebar": "6.3.2", "simplebar": "6.3.3",
"simplebar-react": "3.3.2", "simplebar-react": "3.3.2",
"source-map-support": "0.5.21", "source-map-support": "0.5.21",
"sqlite-vec": "0.1.7-alpha.2", "sqlite-vec": "0.1.7-alpha.2",
"strip-ansi": "^7.1.2", "strip-ansi": "^7.1.2",
"tapable": "^2.3.0", "tapable": "^2.3.0",
"tiddlywiki": "5.3.8", "tiddlywiki": "5.3.8",
"type-fest": "5.1.0", "type-fest": "5.2.0",
"typeorm": "^0.3.27", "typeorm": "^0.3.27",
"typescript-styled-is": "^2.1.0", "typescript-styled-is": "^2.1.0",
"v8-compile-cache-lib": "^3.0.1", "v8-compile-cache-lib": "^3.0.1",
@ -153,37 +153,37 @@
"@types/html-minifier-terser": "^7.0.2", "@types/html-minifier-terser": "^7.0.2",
"@types/intercept-stdout": "0.1.4", "@types/intercept-stdout": "0.1.4",
"@types/lodash": "4.17.20", "@types/lodash": "4.17.20",
"@types/node": "24.9.1", "@types/node": "24.10.1",
"@types/react": "19.2.2", "@types/react": "19.2.6",
"@types/react-dom": "19.2.2", "@types/react-dom": "19.2.3",
"@types/react-jsonschema-form": "^1.7.13", "@types/react-jsonschema-form": "^1.7.13",
"@types/semver": "7.7.1", "@types/semver": "7.7.1",
"@types/source-map-support": "0.5.10", "@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/coverage-v8": "^3.2.4",
"@vitest/ui": "^3.2.4", "@vitest/ui": "^3.2.4",
"chai": "6.2.0", "chai": "6.2.0",
"cross-env": "10.1.0", "cross-env": "10.1.0",
"dprint": "^0.50.2", "dprint": "^0.50.2",
"electron": "38.3.0", "electron": "39.2.3",
"electron-chrome-web-store": "^0.13.0", "electron-chrome-web-store": "^0.13.0",
"esbuild": "^0.25.11", "esbuild": "^0.27.0",
"eslint-config-tidgi": "^2.2.0", "eslint-config-tidgi": "^2.2.0",
"identity-obj-proxy": "^3.0.0", "identity-obj-proxy": "^3.0.0",
"jsdom": "^27.0.1", "jsdom": "^27.2.0",
"memory-fs": "^0.5.0", "memory-fs": "^0.5.0",
"node-loader": "2.1.0", "node-loader": "2.1.0",
"oauth2-mock-server": "^8.1.0", "oauth2-mock-server": "^8.2.0",
"path-browserify": "^1.0.1", "path-browserify": "^1.0.1",
"playwright": "^1.56.1", "playwright": "^1.56.1",
"rimraf": "^6.0.1", "rimraf": "^6.1.2",
"ts-node": "10.9.2", "ts-node": "10.9.2",
"tsx": "^4.20.6", "tsx": "^4.20.6",
"tw5-typed": "^0.6.8", "tw5-typed": "^1.0.5",
"typescript": "5.9.3", "typescript": "5.9.3",
"typesync": "0.14.3", "typesync": "0.14.3",
"unplugin-swc": "^1.5.8", "unplugin-swc": "^1.5.8",
"vite": "^7.1.11", "vite": "^7.2.4",
"vite-bundle-analyzer": "^1.2.3", "vite-bundle-analyzer": "^1.2.3",
"vitest": "^3.2.4" "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]); }, [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(() => { useEffect(() => {
setCurrentStep(tab.currentStep ?? 0); if (tab.currentStep !== undefined) {
}, [tab.currentStep]); setCurrentStep(tab.currentStep);
}
}, []); // Only run once on mount
// Cleanup when component unmounts or tab closes // Cleanup when component unmounts or tab closes
useEffect(() => { useEffect(() => {
@ -359,26 +362,26 @@ export const CreateNewAgentContent: React.FC<CreateNewAgentContentProps> = ({ ta
case 'editPrompt': case 'editPrompt':
return ( return (
<StepContainer> <StepContainer>
<Typography variant='h6' gutterBottom> <Typography variant='h6' gutterBottom data-testid='edit-prompt-title'>
{t('CreateAgent.EditPrompt')} {t('CreateAgent.EditPrompt')}
</Typography> </Typography>
<Typography variant='body2' color='text.secondary' gutterBottom> <Typography variant='body2' color='text.secondary' gutterBottom>
{t('CreateAgent.EditPromptDescription')} {t('CreateAgent.EditPromptDescription')}
</Typography> </Typography>
{temporaryAgentDefinition {temporaryAgentDefinition && promptSchema
? ( ? (
<Box sx={{ mt: 2, height: 400, overflow: 'auto' }}> <Box sx={{ mt: 2, height: 400, overflow: 'auto' }}>
<PromptConfigForm <PromptConfigForm
schema={promptSchema || undefined} schema={promptSchema}
formData={temporaryAgentDefinition.handlerConfig as HandlerConfig} formData={(temporaryAgentDefinition.handlerConfig || {}) as HandlerConfig}
onChange={(updatedConfig) => { onChange={(updatedConfig) => {
void handleAgentDefinitionChange({ void handleAgentDefinitionChange({
...temporaryAgentDefinition, ...temporaryAgentDefinition,
handlerConfig: updatedConfig as Record<string, unknown>, handlerConfig: updatedConfig as Record<string, unknown>,
}); });
}} }}
loading={!promptSchema} loading={false}
/> />
</Box> </Box>
) )

View file

@ -219,22 +219,27 @@ describe('CreateNewAgentContent', () => {
it('should show correct step content based on currentStep', () => { it('should show correct step content based on currentStep', () => {
// Test step 1 (currentStep: 0) - Setup Agent (name + template) // Test step 1 (currentStep: 0) - Setup Agent (name + template)
const step1Tab = { ...mockTab, currentStep: 0 }; 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.getByRole('heading', { name: '设置智能体' })).toBeInTheDocument();
expect(screen.getByLabelText('智能体名称')).toBeInTheDocument(); expect(screen.getByLabelText('智能体名称')).toBeInTheDocument();
expect(screen.getByTestId('template-search-input')).toBeInTheDocument(); expect(screen.getByTestId('template-search-input')).toBeInTheDocument();
// Clean up before next render
unmount();
// Test step 2 (currentStep: 1) - Edit Prompt // Test step 2 (currentStep: 1) - Edit Prompt
const step2Tab = { ...mockTab, currentStep: 1 }; const step2Tab = { ...mockTab, currentStep: 1 };
rerender(<TestComponent tab={step2Tab} />); const { unmount: unmount2 } = render(<TestComponent tab={step2Tab} />);
// Should show editPrompt placeholder when no template selected // Should show editPrompt placeholder when no template selected
expect(screen.getByText('请先选择一个模板')).toBeInTheDocument(); expect(screen.getByText('请先选择一个模板')).toBeInTheDocument();
unmount2();
// Test step 3 (currentStep: 2) - Immediate Use // Test step 3 (currentStep: 2) - Immediate Use
const step3Tab = { ...mockTab, currentStep: 2 }; const step3Tab = { ...mockTab, currentStep: 2 };
rerender(<TestComponent tab={step3Tab} />); render(<TestComponent tab={step3Tab} />);
expect(screen.getByRole('heading', { name: '测试并使用' })).toBeInTheDocument(); 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 './StyledArrayContainer';
export * from './StyledCard'; export * from './StyledCard';
export * from './StyledCollapsible'; 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 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. * 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 * 4. Uses useMemo to prevent unnecessary recalculations
*/ */
export const ConditionalField: React.FC<FieldProps> = (props) => { 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; const condition = uiSchema?.['ui:condition'] as ConditionalFieldConfig | undefined;
@ -28,8 +28,10 @@ export const ConditionalField: React.FC<FieldProps> = (props) => {
if (!rootFormData) return true; if (!rootFormData) return true;
// Parse the field's path to find its parent object where sibling fields are located // Parse the field's path to find its parent object where sibling fields are located
const fieldPath = idSchema.$id.replace(/^root_/, ''); // In RJSF 6.x, fieldPathId.$id is a string that contains the path
const pathParts = fieldPath.split('_'); 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 pathParts.pop(); // Remove current field name to get parent path
// Navigate to parent object in the form data tree // 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 // Apply inverse logic if specified
return hideWhen ? !conditionMet : conditionMet; return hideWhen ? !conditionMet : conditionMet;
}, [condition, registry.formContext, idSchema.$id]); }, [condition, registry.formContext, fieldPathId?.$id]);
// Hidden fields return nothing // Hidden fields return nothing
if (!shouldShow) { if (!shouldShow) {

View file

@ -10,7 +10,7 @@ import { ErrorDisplay } from './components/ErrorDisplay';
import { ArrayItemProvider } from './context/ArrayItemContext'; import { ArrayItemProvider } from './context/ArrayItemContext';
import { useDefaultUiSchema } from './defaultUiSchema'; import { useDefaultUiSchema } from './defaultUiSchema';
import { fields } from './fields'; import { fields } from './fields';
import { ArrayFieldTemplate, FieldTemplate, ObjectFieldTemplate, RootObjectFieldTemplate } from './templates'; import { ArrayFieldItemTemplate, ArrayFieldTemplate, FieldTemplate, ObjectFieldTemplate, RootObjectFieldTemplate } from './templates';
import { widgets } from './widgets'; import { widgets } from './widgets';
/** /**
@ -67,7 +67,7 @@ export const PromptConfigForm: React.FC<PromptConfigFormProps> = ({
const templates = useMemo(() => { const templates = useMemo(() => {
const rootObjectFieldTemplate = (props: ObjectFieldTemplateProps) => { const rootObjectFieldTemplate = (props: ObjectFieldTemplateProps) => {
const isRootLevel = props.idSchema.$id === 'root'; const isRootLevel = props.fieldPathId?.$id === 'root';
return isRootLevel return isRootLevel
? <RootObjectFieldTemplate {...props} /> ? <RootObjectFieldTemplate {...props} />
: <ObjectFieldTemplate {...props} />; : <ObjectFieldTemplate {...props} />;
@ -75,6 +75,8 @@ export const PromptConfigForm: React.FC<PromptConfigFormProps> = ({
return { return {
ArrayFieldTemplate, ArrayFieldTemplate,
ArrayFieldItemTemplate,
FieldTemplate, FieldTemplate,
ObjectFieldTemplate: rootObjectFieldTemplate, ObjectFieldTemplate: rootObjectFieldTemplate,
}; };
@ -145,7 +147,7 @@ export const PromptConfigForm: React.FC<PromptConfigFormProps> = ({
widgets={widgets} widgets={widgets}
fields={fields} fields={fields}
showErrorList={false} showErrorList={false}
liveValidate liveValidate='onChange'
noHtml5Validate noHtml5Validate
> >
<div /> <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 { Box, Typography } from '@mui/material';
import { ArrayFieldTemplateProps } from '@rjsf/utils'; import { ArrayFieldTemplateProps } from '@rjsf/utils';
import React from 'react'; import React from 'react';
import { useTranslation } from 'react-i18next'; 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) => { 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 { 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 description = schema.description;
const itemIds = items.map((item) => item.key);
const isItemsCollapsible = true;
return ( return (
<ArrayContainer> <ArrayContainer>
@ -64,26 +43,9 @@ export const ArrayFieldTemplate: React.FC<ArrayFieldTemplateProps> = (props) =>
</EmptyState> </EmptyState>
) )
: ( : (
<DndContext <Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
sensors={sensors} {items}
modifiers={[restrictToVerticalAxis]} </Box>
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>
)} )}
{canAdd && ( {canAdd && (

View file

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

View file

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

View file

@ -1,8 +1,7 @@
import { Helmet } from '@dr.pogodin/react-helmet'; 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 { lazy } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import is, { isNot } from 'typescript-styled-is';
import { Route, Switch } from 'wouter'; import { Route, Switch } from 'wouter';
import { PageType } from '@/constants/pageTypes'; 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; flex: 1;
display: flex; display: flex;
flex-direction: column; 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%; 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 { export default function Main(): React.JSX.Element {
const { t } = useTranslation(); const { t } = useTranslation();

View file

@ -14,6 +14,7 @@ import { WindowNames } from '@services/windows/WindowProperties';
import { browserViewMetaData } from './common/browserViewMetaData'; import { browserViewMetaData } from './common/browserViewMetaData';
import './view'; import './view';
import { syncTidgiStateWhenWikiLoads } from './appState'; import { syncTidgiStateWhenWikiLoads } from './appState';
import { consoleLogToLogFile } from './fixer/consoleLogToLogFile';
import { fixAlertConfirm } from './fixer/fixAlertConfirm'; import { fixAlertConfirm } from './fixer/fixAlertConfirm';
declare global { declare global {
@ -26,6 +27,9 @@ declare global {
switch (browserViewMetaData.windowName) { switch (browserViewMetaData.windowName) {
case WindowNames.main: { 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. * 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 * @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 { initRendererI18N } from './services/libs/i18n/renderer';
import 'electron-ipc-cat/fixContextIsolation'; import 'electron-ipc-cat/fixContextIsolation';
import { useHashLocation } from 'wouter/use-hash-location'; import { useHashLocation } from 'wouter/use-hash-location';
import { ErrorBoundary } from './components/ErrorBoundary';
import { RootStyle } from './components/RootStyle'; import { RootStyle } from './components/RootStyle';
import { initTestKeyboardShortcutFallback } from './helpers/testKeyboardShortcuts'; import { initTestKeyboardShortcutFallback } from './helpers/testKeyboardShortcuts';
import { Pages } from './windows'; import { Pages } from './windows';
@ -33,6 +34,7 @@ function App(): JSX.Element {
return ( return (
<StrictMode> <StrictMode>
<ErrorBoundary>
<ThemeProvider theme={theme?.shouldUseDarkColors === true ? darkTheme : lightTheme}> <ThemeProvider theme={theme?.shouldUseDarkColors === true ? darkTheme : lightTheme}>
<StyledEngineProvider injectFirst> <StyledEngineProvider injectFirst>
<LocalizationProvider dateAdapter={AdapterDateFns}> <LocalizationProvider dateAdapter={AdapterDateFns}>
@ -49,6 +51,7 @@ function App(): JSX.Element {
</LocalizationProvider> </LocalizationProvider>
</StyledEngineProvider> </StyledEngineProvider>
</ThemeProvider> </ThemeProvider>
</ErrorBoundary>
</StrictMode> </StrictMode>
); );
} }

View file

@ -55,6 +55,12 @@ const labeledLoggers = new Map<string, winston.Logger>();
* @returns A winston logger instance for the specified label * @returns A winston logger instance for the specified label
*/ */
export function getLoggerForLabel(label: string): winston.Logger { 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); const existingLogger = labeledLoggers.get(label);
if (existingLogger) { if (existingLogger) {
return existingLogger; return existingLogger;

View file

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

View file

@ -1,8 +1,8 @@
import type nsfw from 'nsfw'; import type nsfw from 'nsfw';
import path from 'path'; 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 { export interface ISubWikiInfo {
id: string; id: string;

View file

@ -2,10 +2,9 @@ import { git, workspace } from '@services/wiki/wikiWorker/services';
import fs from 'fs'; import fs from 'fs';
import nsfw from 'nsfw'; import nsfw from 'nsfw';
import path from 'path'; import path from 'path';
import type { Tiddler, Wiki } from 'tiddlywiki'; import type { IFileInfo, Tiddler, Wiki } from 'tiddlywiki';
import { FileSystemAdaptor, type IFileSystemAdaptorCallback } from './FileSystemAdaptor'; import { FileSystemAdaptor } from './FileSystemAdaptor';
import { type IBootFilesIndexItemWithTitle, InverseFilesIndex } from './InverseFilesIndex'; import { type IBootFilesIndexItemWithTitle, InverseFilesIndex } from './InverseFilesIndex';
import { getActionName } from './utilities';
/** /**
* Delay before actually processing file deletion. * 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) * Save a tiddler to the filesystem (with file watching support)
* Can be used with callback (legacy) or as async/await * 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 { try {
// Get existing file info (if tiddler already exists on disk) // Get existing file info (if tiddler already exists on disk)
const oldFileInfo = this.boot.files[tiddler.fields.title]; 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) // Exclude old file path before save (if it exists)
if (oldFileInfo) { if (oldFileInfo) {
this.excludeFile(oldFileInfo.filepath); this.excludeFile(oldFileInfo.filepath);
this.logger.log(`[WATCH_FS_SAVE] Excluded existing file: ${oldFileInfo.filepath}`); // Also exclude the .meta file if it exists
} else { const metaFilePath = `${oldFileInfo.filepath}.meta`;
// For new tiddlers, we can't pre-exclude them since we don't know the path yet this.excludeFile(metaFilePath);
this.logger.log(`[WATCH_FS_NEW_TIDDLER] Saving new tiddler: ${tiddler.fields.title}`);
} }
// Call parent's saveTiddler to handle the actual save (including cleanup of old files) // 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 // Schedule re-inclusion after delay to avoid echo
this.scheduleFileInclusion(finalFileInfo.filepath); 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 // If old file path was different and we excluded it, re-include it
// The old file should be deleted by now via cleanupTiddlerFiles // The old file should be deleted by now via cleanupTiddlerFiles
if (oldFileInfo && oldFileInfo.filepath !== finalFileInfo.filepath) { if (oldFileInfo && oldFileInfo.filepath !== finalFileInfo.filepath) {
this.scheduleFileInclusion(oldFileInfo.filepath); this.scheduleFileInclusion(oldFileInfo.filepath);
this.scheduleFileInclusion(`${oldFileInfo.filepath}.meta`);
} }
} catch (error) { } catch (error) {
const errorObject = error instanceof Error ? error : new Error(typeof error === 'string' ? error : 'Unknown 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) * Delete a tiddler from the filesystem (with file watching support)
* Can be used with callback (legacy) or as async/await * 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]; const fileInfo = this.boot.files[title];
if (!fileInfo) { if (!fileInfo) {
@ -195,11 +229,11 @@ export class WatchFileSystemAdaptor extends FileSystemAdaptor {
try { try {
const currentWorkspace = await workspace.get(this.workspaceID); const currentWorkspace = await workspace.get(this.workspaceID);
if (currentWorkspace && 'enableFileSystemWatch' in currentWorkspace && !currentWorkspace.enableFileSystemWatch) { 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; return;
} }
} catch (error) { } 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; return;
} }
} }
@ -225,7 +259,7 @@ export class WatchFileSystemAdaptor extends FileSystemAdaptor {
{ {
debounceMS: 100, debounceMS: 100,
errorCallback: (error) => { errorCallback: (error) => {
this.logger.alert('[WATCH_FS_ERROR] NSFW error:', error); this.logger.alert('WatchFileSystemAdaptor NSFW error:', error);
}, },
// Start with base excluded paths // Start with base excluded paths
// @ts-expect-error - nsfw types are incorrect, it accepts string[] not just [string] // @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 // Log stabilization marker for tests
this.logger.log('[test-id-WATCH_FS_STABILIZED] Watcher has stabilized', { level: 'debug' }); this.logger.log('[test-id-WATCH_FS_STABILIZED] Watcher has stabilized', { level: 'debug' });
} catch (error) { } 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 // Get sub-wikis for this main wiki
const subWikis = await workspace.getSubWorkspacesAsList(this.workspaceID); 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 // Create watcher for each sub-wiki
for (const subWiki of subWikis) { for (const subWiki of subWikis) {
@ -270,7 +304,7 @@ export class WatchFileSystemAdaptor extends FileSystemAdaptor {
// Check if the path exists before trying to watch // Check if the path exists before trying to watch
if (!fs.existsSync(subWikiPath)) { 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; continue;
} }
@ -283,7 +317,7 @@ export class WatchFileSystemAdaptor extends FileSystemAdaptor {
{ {
debounceMS: 100, debounceMS: 100,
errorCallback: (error) => { 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(); await subWikiWatcher.start();
this.inverseFilesIndex.registerSubWiki(subWiki.id, subWikiPath, subWikiWatcher); 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) { } 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) { } 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 * @param absoluteFilePath Absolute file path
*/ */
private excludeFile(absoluteFilePath: string): void { private excludeFile(absoluteFilePath: string): void {
this.logger.log(`[WATCH_FS_EXCLUDE] Excluding file: ${absoluteFilePath}`);
this.inverseFilesIndex.excludeFile(absoluteFilePath); this.inverseFilesIndex.excludeFile(absoluteFilePath);
} }
@ -339,7 +372,6 @@ export class WatchFileSystemAdaptor extends FileSystemAdaptor {
} }
const timer = setTimeout(() => { const timer = setTimeout(() => {
this.logger.log(`[WATCH_FS_INCLUDE] Including file: ${absoluteFilePath}`);
this.inverseFilesIndex.includeFile(absoluteFilePath); this.inverseFilesIndex.includeFile(absoluteFilePath);
this.pendingInclusions.delete(absoluteFilePath); this.pendingInclusions.delete(absoluteFilePath);
// Notify git service when file is included after being saved // Notify git service when file is included after being saved
@ -359,7 +391,7 @@ export class WatchFileSystemAdaptor extends FileSystemAdaptor {
if (existingTimer) { if (existingTimer) {
clearTimeout(existingTimer); clearTimeout(existingTimer);
this.pendingDeletions.delete(fileAbsolutePath); 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); }, FILE_DELETION_DELAY_MS);
this.pendingDeletions.set(fileAbsolutePath, timer); 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); : this.inverseFilesIndex.isMainFileExcluded(fileAbsolutePath);
if (isExcluded) { if (isExcluded) {
this.logger.log(`[WATCH_FS_SKIP_EXCLUDED] Skipping excluded file: ${fileAbsolutePath}`); this.logger.log(`WatchFileSystemAdaptor Skipping excluded file: ${fileAbsolutePath}`);
continue; continue;
} }
@ -428,10 +459,20 @@ export class WatchFileSystemAdaptor extends FileSystemAdaptor {
const fileMimeType = $tw.utils.getFileExtensionInfo(fileExtension)?.type ?? 'text/vnd.tiddlywiki'; const fileMimeType = $tw.utils.getFileExtensionInfo(fileExtension)?.type ?? 'text/vnd.tiddlywiki';
const metaFileAbsolutePath = `${fileAbsolutePath}.meta`; const metaFileAbsolutePath = `${fileAbsolutePath}.meta`;
this.logger.log('[WATCH_FS_EVENT]', getActionName(action), fileName, `(directory: ${directory})`);
// Handle different event types // Handle different event types
if (action === nsfw.actions.CREATED || action === nsfw.actions.MODIFIED) { 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) // Cancel any pending deletion for this file (e.g., git revert scenario)
this.cancelPendingDeletion(fileAbsolutePath); this.cancelPendingDeletion(fileAbsolutePath);
@ -508,13 +549,9 @@ export class WatchFileSystemAdaptor extends FileSystemAdaptor {
// watchPathBase is wiki/tiddlers, but wikiFolderLocation should be wiki // watchPathBase is wiki/tiddlers, but wikiFolderLocation should be wiki
const wikiFolderLocation = path.dirname(this.watchPathBase); const wikiFolderLocation = path.dirname(this.watchPathBase);
try { try {
(git.notifyFileChange as ((path: string, options?: { onlyWhenGitLogOpened?: boolean }) => void))( void git.notifyFileChange(wikiFolderLocation, { onlyWhenGitLogOpened: true });
wikiFolderLocation,
{ onlyWhenGitLogOpened: true },
);
this.logger.log(`[WATCH_FS_GIT_NOTIFY] Notified git service about file changes in ${wikiFolderLocation}`);
} catch (error) { } 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; this.gitNotificationTimer = undefined;
}, GIT_NOTIFICATION_DELAY_MS); }, GIT_NOTIFICATION_DELAY_MS);
@ -547,7 +584,7 @@ export class WatchFileSystemAdaptor extends FileSystemAdaptor {
try { try {
tiddlersDescriptor = $tw.loadTiddlersFromFile(actualFileToLoad); tiddlersDescriptor = $tw.loadTiddlersFromFile(actualFileToLoad);
} catch (error) { } 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; return;
} }
@ -573,7 +610,7 @@ export class WatchFileSystemAdaptor extends FileSystemAdaptor {
// not wrapped in a .fields property // not wrapped in a .fields property
const tiddlerTitle = tiddler?.title; const tiddlerTitle = tiddler?.title;
if (!tiddlerTitle) { 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; continue;
} }
@ -587,7 +624,8 @@ export class WatchFileSystemAdaptor extends FileSystemAdaptor {
} as IBootFilesIndexItemWithTitle); } as IBootFilesIndexItemWithTitle);
// Add tiddler to wiki (this will update if it exists or add if new) // 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 // Log appropriate event
if (isNewFile) { if (isNewFile) {
@ -625,7 +663,8 @@ export class WatchFileSystemAdaptor extends FileSystemAdaptor {
} }
// Check if tiddler exists in wiki before trying to delete // 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 // Tiddler doesn't exist in wiki, nothing to delete
return; return;
} }
@ -634,7 +673,7 @@ export class WatchFileSystemAdaptor extends FileSystemAdaptor {
this.removeTiddlerFileInfo(tiddlerTitle); this.removeTiddlerFileInfo(tiddlerTitle);
// Delete the tiddler from wiki to trigger change event // 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' }); this.logger.log(`[test-id-WATCH_FS_TIDDLER_DELETED] ${tiddlerTitle}`, { level: 'debug' });
// Delete system tiddler empty file if exists // Delete system tiddler empty file if exists
@ -677,15 +716,15 @@ export class WatchFileSystemAdaptor extends FileSystemAdaptor {
this.pendingInclusions.clear(); this.pendingInclusions.clear();
if (this.watcher) { if (this.watcher) {
this.logger.log('[WATCH_FS_CLEANUP] Closing filesystem watcher'); this.logger.log('WatchFileSystemAdaptor Closing filesystem watcher');
await this.watcher.stop(); await this.watcher.stop();
this.watcher = undefined; this.watcher = undefined;
this.logger.log('[WATCH_FS_CLEANUP] Filesystem watcher closed'); this.logger.log('WatchFileSystemAdaptor Filesystem watcher closed');
} }
// Close all sub-wiki watchers // Close all sub-wiki watchers
for (const subWiki of this.inverseFilesIndex.getSubWikis()) { 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(); await subWiki.watcher.stop();
this.inverseFilesIndex.unregisterSubWiki(subWiki.id); 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 * 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 { Observable } from 'rxjs';
import type { IWikiWorkspace } from '@services/workspaces/interface'; import type { IWikiWorkspace } from '@services/workspaces/interface';
import type { SyncAdaptor } from 'tiddlywiki';
import { IZxWorkerMessage, ZxWorkerControlActions } from '../interface'; import { IZxWorkerMessage, ZxWorkerControlActions } from '../interface';
import { executeScriptInTWContext, executeScriptInZxScriptContext, extractTWContextScripts, type IVariableContextList } from '../plugin/zxPlugin'; import { executeScriptInTWContext, executeScriptInZxScriptContext, extractTWContextScripts, type IVariableContextList } from '../plugin/zxPlugin';
import { wikiOperationsInWikiWorker } from '../wikiOperations/executor/wikiOperationInServer'; import { wikiOperationsInWikiWorker } from '../wikiOperations/executor/wikiOperationInServer';
@ -101,7 +100,7 @@ async function beforeExit(): Promise<void> {
// Cleanup watch-filesystem adaptor // Cleanup watch-filesystem adaptor
const wikiInstance = getWikiInstance(); const wikiInstance = getWikiInstance();
// Call our custom cleanup method if it exists `src/services/wiki/plugin/watchFileSystemAdaptor/watch-filesystem-adaptor.ts` // 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) { if (syncAdaptor?.cleanup) {
await 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 { DARK_LIGHT_CHANGE_ACTIONS_TAG } from '@services/theme/interface';
import intercept from 'intercept-stdout'; import intercept from 'intercept-stdout';
import { nanoid } from 'nanoid'; import { nanoid } from 'nanoid';
import type { Server } from 'node:http';
import inspector from 'node:inspector'; import inspector from 'node:inspector';
import path from 'path'; import path from 'path';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
@ -180,8 +181,8 @@ export function startNodeJSWiki({
: [homePath, '--version']; : [homePath, '--version'];
wikiInstance.boot.argv = [...fullBootArgv]; wikiInstance.boot.argv = [...fullBootArgv];
wikiInstance.hooks.addHook('th-server-command-post-start', function(_listenCommand, server) { wikiInstance.hooks.addHook('th-server-command-post-start', function(_server: unknown, nodeServer: Server) {
server.on('error', function(error: Error) { nodeServer.on('error', function(error: Error) {
observer.next({ type: 'control', actions: WikiControlActions.error, message: error.message, argv: fullBootArgv }); observer.next({ type: 'control', actions: WikiControlActions.error, message: error.message, argv: fullBootArgv });
}); });
// Similar to how updateActiveWikiTheme calls WikiChannel.invokeActionsByTag // 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, { wikiInstance.rootWidget.invokeActionsByTag(DARK_LIGHT_CHANGE_ACTIONS_TAG, new Event('TidGi-invokeActionByTag') as unknown as IWidgetEvent, {
'dark-mode': shouldUseDarkColors ? 'yes' : 'no', 'dark-mode': shouldUseDarkColors ? 'yes' : 'no',
}); });
server.on('listening', function() { nodeServer.on('listening', function() {
observer.next({ observer.next({
type: 'control', type: 'control',
actions: WikiControlActions.listening, actions: WikiControlActions.listening,

View file

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

View file

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