42 Commits

Author SHA1 Message Date
7dd8e3c763 Merge pull request #88 from lordmathis/docs/screenshot
Add app screenshot to README
2025-11-12 18:52:23 +01:00
0c3847241b Add app screenshot to README 2025-11-12 18:46:43 +01:00
9a8f2e8a46 Merge pull request #87 from lordmathis/fix/default-file-text
Update default file content to reflect project name change
2025-11-12 18:30:48 +01:00
914f9a68f4 Update default file content to reflect project name change 2025-11-12 18:27:09 +01:00
33c93e40d1 Merge pull request #86 from lordmathis/docs/readme-update
Update README
2025-11-11 22:40:54 +01:00
aace38d1a0 Improve README features section 2025-11-11 22:24:46 +01:00
71bd791c60 Update environment variable configuration section in README 2025-11-11 22:18:44 +01:00
74aeeec42b Merge pull request #85 from lordmathis/feat/wikilinks-autocomplete
Add autocompletion for wiki links
2025-11-11 22:05:57 +01:00
2e7bd88a57 Add autocompletion for wiki links 2025-11-11 21:56:18 +01:00
0579b8d0e5 Merge pull request #84 from lordmathis/feat/new-file-folder
Improve create file modal with parent folder selection
2025-11-11 20:41:51 +01:00
93f484eb91 Close folder selection popover after selecting a folder in CreateFileModal 2025-11-11 20:11:51 +01:00
bc49391b5c Add FolderSelector component and integrate with CreateFileModal 2025-11-11 20:05:27 +01:00
c98ece29d9 Merge pull request #78 from lordmathis/dependabot/npm_and_yarn/app/minor-and-patch-14e79d6468
Bump the minor-and-patch group in /app with 2 updates
2025-11-10 20:01:12 +01:00
dependabot[bot]
406483c83e Bump the minor-and-patch group in /app with 2 updates
Bumps the minor-and-patch group in /app with 2 updates: [@typescript-eslint/eslint-plugin](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/eslint-plugin) and [@typescript-eslint/parser](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/parser).


Updates `@typescript-eslint/eslint-plugin` from 8.46.3 to 8.46.4
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.46.4/packages/eslint-plugin)

Updates `@typescript-eslint/parser` from 8.46.3 to 8.46.4
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/parser/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.46.4/packages/parser)

---
updated-dependencies:
- dependency-name: "@typescript-eslint/eslint-plugin"
  dependency-version: 8.46.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: "@typescript-eslint/parser"
  dependency-version: 8.46.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-10 18:50:45 +00:00
b285436081 Merge pull request #83 from lordmathis/chore/update-deps
Update dependencies
2025-11-10 19:32:08 +01:00
aeeab0e37a Fix import statement in vitest.config.ts 2025-11-10 19:27:47 +01:00
414000d72f Fix tests after deps update 2025-11-10 19:18:55 +01:00
ffa10a8411 Bump @mantine packages to version 8.3.7 in package.json and package-lock.json 2025-11-10 18:45:02 +01:00
73a72b64be Update vitest and coverage-v8 deps 2025-11-10 18:41:32 +01:00
681b5a857c Merge pull request #77 from lordmathis/dependabot/npm_and_yarn/app/rehype-mathjax-7.1.0
Bump rehype-mathjax from 6.0.0 to 7.1.0 in /app
2025-11-04 23:53:33 +01:00
dependabot[bot]
bc97c21b1d Bump rehype-mathjax from 6.0.0 to 7.1.0 in /app
Bumps [rehype-mathjax](https://github.com/remarkjs/remark-math) from 6.0.0 to 7.1.0.
- [Release notes](https://github.com/remarkjs/remark-math/releases)
- [Commits](https://github.com/remarkjs/remark-math/compare/rehype-mathjax@6.0.0...rehype-mathjax@7.1.0)

---
updated-dependencies:
- dependency-name: rehype-mathjax
  dependency-version: 7.1.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-04 22:41:26 +00:00
a2cd260bca Merge pull request #72 from lordmathis/dependabot/npm_and_yarn/app/minor-and-patch-406c606fe0
Bump the minor-and-patch group in /app with 18 updates
2025-11-04 20:34:51 +01:00
dependabot[bot]
85f2bf23c1 Bump the minor-and-patch group in /app with 18 updates
Bumps the minor-and-patch group in /app with 18 updates:

| Package | From | To |
| --- | --- | --- |
| [@codemirror/commands](https://github.com/codemirror/commands) | `6.6.2` | `6.10.0` |
| [@codemirror/lang-markdown](https://github.com/codemirror/lang-markdown) | `6.2.5` | `6.5.0` |
| [@codemirror/state](https://github.com/codemirror/state) | `6.4.1` | `6.5.2` |
| [@codemirror/theme-one-dark](https://github.com/codemirror/theme-one-dark) | `6.1.2` | `6.1.3` |
| [@codemirror/view](https://github.com/codemirror/view) | `6.34.0` | `6.38.6` |
| [@tabler/icons-react](https://github.com/tabler/tabler-icons/tree/HEAD/packages/icons-react) | `3.19.0` | `3.35.0` |
| [codemirror](https://github.com/codemirror/basic-setup) | `6.0.1` | `6.0.2` |
| [react-arborist](https://github.com/brimdata/react-arborist) | `3.4.0` | `3.4.3` |
| [remark-rehype](https://github.com/remarkjs/remark-rehype) | `11.1.1` | `11.1.2` |
| [@eslint/compat](https://github.com/eslint/rewrite/tree/HEAD/packages/compat) | `1.2.9` | `1.4.1` |
| [@testing-library/jest-dom](https://github.com/testing-library/jest-dom) | `6.6.3` | `6.9.1` |
| [@typescript-eslint/eslint-plugin](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/eslint-plugin) | `8.32.1` | `8.46.3` |
| [@typescript-eslint/parser](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/parser) | `8.32.1` | `8.46.3` |
| [eslint](https://github.com/eslint/eslint) | `9.27.0` | `9.39.1` |
| [postcss](https://github.com/postcss/postcss) | `8.5.3` | `8.5.6` |
| [postcss-preset-mantine](https://github.com/mantinedev/postcss-preset-mantine) | `1.17.0` | `1.18.0` |
| [sass](https://github.com/sass/dart-sass) | `1.80.4` | `1.93.3` |
| [typescript](https://github.com/microsoft/TypeScript) | `5.8.3` | `5.9.3` |


Updates `@codemirror/commands` from 6.6.2 to 6.10.0
- [Changelog](https://github.com/codemirror/commands/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/commands/compare/6.6.2...6.10.0)

Updates `@codemirror/lang-markdown` from 6.2.5 to 6.5.0
- [Changelog](https://github.com/codemirror/lang-markdown/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/lang-markdown/compare/6.2.5...6.5.0)

Updates `@codemirror/state` from 6.4.1 to 6.5.2
- [Changelog](https://github.com/codemirror/state/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/state/compare/6.4.1...6.5.2)

Updates `@codemirror/theme-one-dark` from 6.1.2 to 6.1.3
- [Changelog](https://github.com/codemirror/theme-one-dark/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/theme-one-dark/compare/6.1.2...6.1.3)

Updates `@codemirror/view` from 6.34.0 to 6.38.6
- [Changelog](https://github.com/codemirror/view/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/view/compare/6.34.0...6.38.6)

Updates `@tabler/icons-react` from 3.19.0 to 3.35.0
- [Release notes](https://github.com/tabler/tabler-icons/releases)
- [Commits](https://github.com/tabler/tabler-icons/commits/v3.35.0/packages/icons-react)

Updates `codemirror` from 6.0.1 to 6.0.2
- [Changelog](https://github.com/codemirror/basic-setup/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/basic-setup/compare/6.0.1...6.0.2)

Updates `react-arborist` from 3.4.0 to 3.4.3
- [Release notes](https://github.com/brimdata/react-arborist/releases)
- [Changelog](https://github.com/brimdata/react-arborist/blob/main/CHANGELOG.md)
- [Commits](https://github.com/brimdata/react-arborist/compare/v3.4.0...v3.4.3)

Updates `remark-rehype` from 11.1.1 to 11.1.2
- [Release notes](https://github.com/remarkjs/remark-rehype/releases)
- [Commits](https://github.com/remarkjs/remark-rehype/compare/11.1.1...11.1.2)

Updates `@eslint/compat` from 1.2.9 to 1.4.1
- [Release notes](https://github.com/eslint/rewrite/releases)
- [Changelog](https://github.com/eslint/rewrite/blob/main/packages/compat/CHANGELOG.md)
- [Commits](https://github.com/eslint/rewrite/commits/compat-v1.4.1/packages/compat)

Updates `@testing-library/jest-dom` from 6.6.3 to 6.9.1
- [Release notes](https://github.com/testing-library/jest-dom/releases)
- [Changelog](https://github.com/testing-library/jest-dom/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/jest-dom/compare/v6.6.3...v6.9.1)

Updates `@typescript-eslint/eslint-plugin` from 8.32.1 to 8.46.3
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.46.3/packages/eslint-plugin)

Updates `@typescript-eslint/parser` from 8.32.1 to 8.46.3
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/parser/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.46.3/packages/parser)

Updates `eslint` from 9.27.0 to 9.39.1
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/compare/v9.27.0...v9.39.1)

Updates `postcss` from 8.5.3 to 8.5.6
- [Release notes](https://github.com/postcss/postcss/releases)
- [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss/compare/8.5.3...8.5.6)

Updates `postcss-preset-mantine` from 1.17.0 to 1.18.0
- [Commits](https://github.com/mantinedev/postcss-preset-mantine/commits)

Updates `sass` from 1.80.4 to 1.93.3
- [Release notes](https://github.com/sass/dart-sass/releases)
- [Changelog](https://github.com/sass/dart-sass/blob/main/CHANGELOG.md)
- [Commits](https://github.com/sass/dart-sass/compare/1.80.4...1.93.3)

Updates `typescript` from 5.8.3 to 5.9.3
- [Release notes](https://github.com/microsoft/TypeScript/releases)
- [Changelog](https://github.com/microsoft/TypeScript/blob/main/azure-pipelines.release-publish.yml)
- [Commits](https://github.com/microsoft/TypeScript/compare/v5.8.3...v5.9.3)

---
updated-dependencies:
- dependency-name: "@codemirror/commands"
  dependency-version: 6.10.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: "@codemirror/lang-markdown"
  dependency-version: 6.5.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: "@codemirror/state"
  dependency-version: 6.5.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: "@codemirror/theme-one-dark"
  dependency-version: 6.1.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: "@codemirror/view"
  dependency-version: 6.38.6
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: "@tabler/icons-react"
  dependency-version: 3.35.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: codemirror
  dependency-version: 6.0.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: react-arborist
  dependency-version: 3.4.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: remark-rehype
  dependency-version: 11.1.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: "@eslint/compat"
  dependency-version: 1.4.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: "@testing-library/jest-dom"
  dependency-version: 6.9.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: "@typescript-eslint/eslint-plugin"
  dependency-version: 8.46.3
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: "@typescript-eslint/parser"
  dependency-version: 8.46.3
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: eslint
  dependency-version: 9.39.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: postcss
  dependency-version: 8.5.6
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: postcss-preset-mantine
  dependency-version: 1.18.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: sass
  dependency-version: 1.93.3
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: typescript
  dependency-version: 5.9.3
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-04 18:20:06 +00:00
8f06d39a71 Merge pull request #73 from lordmathis/fix/compression
Fix asset compression
2025-11-04 19:17:20 +01:00
93f95f22ef Merge branch 'main' into fix/compression 2025-11-04 19:12:50 +01:00
0a4abdb48c Add Vary header 2025-11-04 19:04:26 +01:00
4ad5e682a8 Enhance StaticHandler to support brotli compression and update content type handling 2025-11-04 19:02:38 +01:00
dd55c81b51 Merge pull request #71 from lordmathis/lordmathis-patch-1
Create dependabot.yml
2025-11-04 18:59:49 +01:00
0939bc7213 Fix generating compressed assets 2025-11-04 18:58:33 +01:00
b8a9cee04d Create dependabot.yml 2025-11-04 18:55:51 +01:00
2259e7400a Merge pull request #69 from lordmathis/fix/drag-and-drop
Fix FileTree drag and drop
2025-11-04 18:25:09 +01:00
2045d36211 Fix FileTree drag and drop 2025-11-04 18:19:54 +01:00
76ab168c6e Merge pull request #68 from lordmathis/feat/user-theme
Add user theme setting
2025-11-03 23:43:23 +01:00
6117f7a58f Add theme property to mock user in ProfileSettings tests 2025-11-03 23:35:54 +01:00
ab6cb47047 Add theme attribute to test user creation 2025-11-03 20:53:54 +01:00
4acba662b6 Refactor theme toggle layout in ProfileSettings component 2025-11-03 19:11:50 +01:00
054d9da867 Fix user type guard to validate theme 2025-11-03 17:52:00 +01:00
efdc42cbd7 Add theme support to user settings and related components 2025-10-28 23:14:45 +01:00
3926954b74 Add theme to user preferences 2025-10-28 20:05:12 +01:00
6c408fdfbe Merge pull request #67 from lordmathis/fix/file-upload
Fix file upload
2025-10-28 19:19:01 +01:00
6753f32520 Remove test case for uploading with missing file_path parameter 2025-10-28 19:09:46 +01:00
de06939b01 Fix file upload 2025-10-28 18:58:10 +01:00
62 changed files with 2308 additions and 1609 deletions

21
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,21 @@
version: 2
updates:
- package-ecosystem: "npm"
directory: "/app"
schedule:
interval: "weekly"
groups:
minor-and-patch:
update-types:
- "minor"
- "patch"
- package-ecosystem: "gomod"
directory: "/"
schedule:
interval: "weekly"
groups:
minor-and-patch:
update-types:
- "minor"
- "patch"

BIN
.github/screenshot.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

6
.gitignore vendored
View File

@@ -162,3 +162,9 @@ go.work.sum
main main
*.db *.db
data data
# Feature specifications
spec.md
# Go debug files
__debug_bin*

View File

@@ -4,15 +4,21 @@
Yet another markdown editor. Work in progress Yet another markdown editor. Work in progress
![App Screenshot](./.github/screenshot.png)
## Features ## Features
- Markdown editing with syntax highlighting - **Editing & Content**
- File tree navigation - **Rich Markdown Editing** - Full-featured editor with syntax highlighting and live preview
- Git integration for version control - **Wikilinks Support** - Create interconnected notes with `[[wikilink]]` syntax and smart autocomplete
- Dark and light theme support - **Math Equations** - Render beautiful mathematical expressions with MathJax support
- Multiple workspaces - **Code Highlighting** - Syntax highlighting for code blocks in multiple languages
- Math equation support (MathJax) - **Organization & Workflow**
- Code syntax highlighting - **File Tree Navigation** - Organized folder structure with intuitive file management
- **Multi-Workspace** - Manage multiple projects and note collections in one place
- **Git Integration** - Built-in version control to track changes and collaborate safely
- **Customization**
- **Theme Flexibility** - Switch between dark and light modes to match your preference
## Prerequisites ## Prerequisites
@@ -22,33 +28,32 @@ Yet another markdown editor. Work in progress
## Configuration ## Configuration
Lemma can be configured using environment variables. Here are the available configuration options: Lemma is configured using environment variables.
### Required Environment Variables ### Environment Variables
- `LEMMA_ADMIN_EMAIL`: Email address for the admin account | Variable | Required | Default | Description |
- `LEMMA_ADMIN_PASSWORD`: Password for the admin account | --------------------------- | -------- | ------------------- | -------------------------------------------------------------------------------------------------------- |
| `LEMMA_ADMIN_EMAIL` | Yes | - | Email address for the admin account |
### Optional Environment Variables | `LEMMA_ADMIN_PASSWORD` | Yes | - | Password for the admin account |
| `LEMMA_ENV` | No | production | Set to "development" to enable development mode |
- `LEMMA_ENV`: Set to "development" to enable development mode | `LEMMA_DB_URL` | No | `sqlite://lemma.db` | Database connection string (supports `sqlite://`, `sqlite3://`, `postgres://`, `postgresql://` prefixes) |
- `LEMMA_DB_URL`: URL (Connection string) to the database. Supported databases are sqlite and postgres a (default: "./lemma.db") | `LEMMA_WORKDIR` | No | `./data` | Working directory for application data |
- `LEMMA_WORKDIR`: Working directory for application data (default: "sqlite://lemma.db") | `LEMMA_STATIC_PATH` | No | `../app/dist` | Path to static files |
- `LEMMA_STATIC_PATH`: Path to static files (default: "../app/dist") | `LEMMA_PORT` | No | `8080` | Port to run the server on |
- `LEMMA_PORT`: Port to run the server on (default: "8080") | `LEMMA_DOMAIN` | No | - | Domain name for cookie authentication |
- `LEMMA_DOMAIN`: Domain name where the application is hosted for cookie authentication | `LEMMA_CORS_ORIGINS` | No | - | Comma-separated list of allowed CORS origins |
- `LEMMA_CORS_ORIGINS`: Comma-separated list of allowed CORS origins | `LEMMA_ENCRYPTION_KEY` | No | auto-generated | Base64-encoded 32-byte key for encrypting sensitive data |
- `LEMMA_ENCRYPTION_KEY`: Base64-encoded 32-byte key used for encrypting sensitive data. If not provided, a key will be automatically generated and stored in `{LEMMA_WORKDIR}/secrets/encryption_key` | `LEMMA_JWT_SIGNING_KEY` | No | auto-generated | Key used for signing JWT tokens |
- `LEMMA_JWT_SIGNING_KEY`: Key used for signing JWT tokens. If not provided, a key will be automatically generated and stored in `{LEMMA_WORKDIR}/secrets/jwt_signing_key` | `LEMMA_LOG_LEVEL` | No | DEBUG/INFO\* | Logging level (\*DEBUG in dev, INFO in production) |
- `LEMMA_LOG_LEVEL`: Logging level (defaults to DEBUG in development mode, INFO in production) | `LEMMA_RATE_LIMIT_REQUESTS` | No | `100` | Number of allowed requests per window |
- `LEMMA_RATE_LIMIT_REQUESTS`: Number of allowed requests per window (default: 100) | `LEMMA_RATE_LIMIT_WINDOW` | No | `15m` | Duration of the rate limit window |
- `LEMMA_RATE_LIMIT_WINDOW`: Duration of the rate limit window (default: 15m)
### Security Keys ### Security Keys
Both the encryption key and JWT signing key are automatically generated on first startup if not provided via environment variables. The keys are stored in `{LEMMA_WORKDIR}/secrets/` with restrictive file permissions (0600). Security keys (`LEMMA_ENCRYPTION_KEY` and `LEMMA_JWT_SIGNING_KEY`) are automatically generated on first startup if not provided. Keys are stored in `{LEMMA_WORKDIR}/secrets/`.
**Important**: Back up the `secrets` directory! If these keys are lost, encrypted data will become inaccessible and all users will need to re-authenticate. **Important:** Back up the `secrets` directory!
## Running the backend server ## Running the backend server

2205
app/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -29,55 +29,56 @@
}, },
"homepage": "https://github.com/LordMathis/Lemma#readme", "homepage": "https://github.com/LordMathis/Lemma#readme",
"dependencies": { "dependencies": {
"@codemirror/commands": "^6.6.2", "@codemirror/autocomplete": "^6.19.1",
"@codemirror/lang-markdown": "^6.2.5", "@codemirror/commands": "^6.10.0",
"@codemirror/state": "^6.4.1", "@codemirror/lang-markdown": "^6.5.0",
"@codemirror/theme-one-dark": "^6.1.2", "@codemirror/state": "^6.5.2",
"@codemirror/view": "^6.34.0", "@codemirror/theme-one-dark": "^6.1.3",
"@mantine/core": "^7.13.2", "@codemirror/view": "^6.38.6",
"@mantine/hooks": "^7.13.2", "@mantine/core": "^8.3.7",
"@mantine/modals": "^7.13.2", "@mantine/hooks": "^8.3.7",
"@mantine/notifications": "^7.13.2", "@mantine/modals": "^8.3.7",
"@mantine/notifications": "^8.3.7",
"@react-hook/resize-observer": "^2.0.2", "@react-hook/resize-observer": "^2.0.2",
"@tabler/icons-react": "^3.19.0", "@tabler/icons-react": "^3.35.0",
"codemirror": "^6.0.1", "codemirror": "^6.0.2",
"react": "^18.3.1", "react": "^18.3.1",
"react-arborist": "^3.4.0", "react-arborist": "^3.4.3",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"rehype-highlight": "^7.0.2", "rehype-highlight": "^7.0.2",
"rehype-mathjax": "^6.0.0", "rehype-mathjax": "^7.1.0",
"rehype-react": "^8.0.0", "rehype-react": "^8.0.0",
"remark": "^15.0.1", "remark": "^15.0.1",
"remark-math": "^6.0.0", "remark-math": "^6.0.0",
"remark-parse": "^11.0.0", "remark-parse": "^11.0.0",
"remark-rehype": "^11.1.1", "remark-rehype": "^11.1.2",
"unified": "^11.0.5", "unified": "^11.0.5",
"unist-util-visit": "^5.0.0" "unist-util-visit": "^5.0.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/compat": "^1.2.9", "@eslint/compat": "^1.4.1",
"@testing-library/jest-dom": "^6.6.3", "@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0", "@testing-library/react": "^16.3.0",
"@types/babel__core": "^7.20.5", "@types/babel__core": "^7.20.5",
"@types/node": "^22.14.0", "@types/node": "^22.14.0",
"@types/react": "^18.3.20", "@types/react": "^18.3.20",
"@types/react-dom": "^18.3.6", "@types/react-dom": "^18.3.6",
"@typescript-eslint/eslint-plugin": "^8.32.1", "@typescript-eslint/eslint-plugin": "^8.46.4",
"@typescript-eslint/parser": "^8.32.1", "@typescript-eslint/parser": "^8.32.1",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
"@vitest/coverage-v8": "^3.1.4", "@vitest/coverage-v8": "^4.0.8",
"eslint": "^9.27.0", "eslint": "^9.39.1",
"eslint-plugin-react": "^7.37.5", "eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-hooks": "^5.2.0",
"jsdom": "^26.1.0", "jsdom": "^26.1.0",
"postcss": "^8.4.47", "postcss": "^8.5.6",
"postcss-preset-mantine": "^1.17.0", "postcss-preset-mantine": "^1.18.0",
"postcss-simple-vars": "^7.0.1", "postcss-simple-vars": "^7.0.1",
"sass": "^1.80.4", "sass": "^1.93.3",
"typescript": "^5.8.2", "typescript": "^5.9.3",
"vite": "^6.4.1", "vite": "^6.4.1",
"vite-plugin-compression2": "^1.3.0", "vite-plugin-compression2": "^2.3.1",
"vitest": "^3.1.4" "vitest": "^4.0.8"
}, },
"browserslist": { "browserslist": {
"production": [ "production": [

View File

@@ -29,10 +29,16 @@ export const apiCall = async (
// Set up headers with CSRF token for non-GET requests // Set up headers with CSRF token for non-GET requests
const method = options.method || 'GET'; const method = options.method || 'GET';
const headers: Record<string, string> = { const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(options.headers as Record<string, string>), ...(options.headers as Record<string, string>),
}; };
// Only set Content-Type to application/json if not already set and body is not FormData
// FormData requires the browser to set Content-Type with the boundary parameter
const isFormData = options.body instanceof FormData;
if (!headers['Content-Type'] && !isFormData) {
headers['Content-Type'] = 'application/json';
}
// Add CSRF token for non-GET methods // Add CSRF token for non-GET methods
if (method !== 'GET') { if (method !== 'GET') {
const csrfToken = getCsrfToken(); const csrfToken = getCsrfToken();
@@ -41,11 +47,18 @@ export const apiCall = async (
} }
} }
// For FormData, don't include Content-Type in headers - let the browser set it
const fetchHeaders = isFormData
? Object.fromEntries(
Object.entries(headers).filter(([key]) => key !== 'Content-Type')
)
: headers;
const response = await fetch(url, { const response = await fetch(url, {
...options, ...options,
// Include credentials to send/receive cookies // Include credentials to send/receive cookies
credentials: 'include', credentials: 'include',
headers, headers: fetchHeaders,
}); });
console.debug(`Response status: ${response.status} for URL: ${url}`); console.debug(`Response status: ${response.status} for URL: ${url}`);

View File

@@ -104,6 +104,7 @@ describe('ContentView', () => {
handleContentChange={mockHandleContentChange} handleContentChange={mockHandleContentChange}
handleSave={mockHandleSave} handleSave={mockHandleSave}
handleFileSelect={mockHandleFileSelect} handleFileSelect={mockHandleFileSelect}
files={[]}
/> />
</TestWrapper> </TestWrapper>
); );
@@ -121,6 +122,7 @@ describe('ContentView', () => {
handleContentChange={mockHandleContentChange} handleContentChange={mockHandleContentChange}
handleSave={mockHandleSave} handleSave={mockHandleSave}
handleFileSelect={mockHandleFileSelect} handleFileSelect={mockHandleFileSelect}
files={[]}
/> />
</TestWrapper> </TestWrapper>
); );
@@ -138,6 +140,7 @@ describe('ContentView', () => {
handleContentChange={mockHandleContentChange} handleContentChange={mockHandleContentChange}
handleSave={mockHandleSave} handleSave={mockHandleSave}
handleFileSelect={mockHandleFileSelect} handleFileSelect={mockHandleFileSelect}
files={[]}
/> />
</TestWrapper> </TestWrapper>
); );
@@ -157,6 +160,7 @@ describe('ContentView', () => {
handleContentChange={mockHandleContentChange} handleContentChange={mockHandleContentChange}
handleSave={mockHandleSave} handleSave={mockHandleSave}
handleFileSelect={mockHandleFileSelect} handleFileSelect={mockHandleFileSelect}
files={[]}
/> />
</TestWrapper> </TestWrapper>
); );
@@ -179,6 +183,7 @@ describe('ContentView', () => {
handleContentChange={mockHandleContentChange} handleContentChange={mockHandleContentChange}
handleSave={mockHandleSave} handleSave={mockHandleSave}
handleFileSelect={mockHandleFileSelect} handleFileSelect={mockHandleFileSelect}
files={[]}
/> />
</TestWrapper> </TestWrapper>
); );
@@ -208,6 +213,7 @@ describe('ContentView', () => {
handleContentChange={mockHandleContentChange} handleContentChange={mockHandleContentChange}
handleSave={mockHandleSave} handleSave={mockHandleSave}
handleFileSelect={mockHandleFileSelect} handleFileSelect={mockHandleFileSelect}
files={[]}
/> />
</TestWrapper> </TestWrapper>
); );

View File

@@ -4,6 +4,7 @@ import Editor from './Editor';
import MarkdownPreview from './MarkdownPreview'; import MarkdownPreview from './MarkdownPreview';
import { getFileUrl, isImageFile } from '../../utils/fileHelpers'; import { getFileUrl, isImageFile } from '../../utils/fileHelpers';
import { useWorkspace } from '@/contexts/WorkspaceContext'; import { useWorkspace } from '@/contexts/WorkspaceContext';
import type { FileNode } from '../../types/models';
type ViewTab = 'source' | 'preview'; type ViewTab = 'source' | 'preview';
@@ -14,6 +15,7 @@ interface ContentViewProps {
handleContentChange: (content: string) => void; handleContentChange: (content: string) => void;
handleSave: (filePath: string, content: string) => Promise<boolean>; handleSave: (filePath: string, content: string) => Promise<boolean>;
handleFileSelect: (filePath: string | null) => Promise<void>; handleFileSelect: (filePath: string | null) => Promise<void>;
files: FileNode[];
} }
const ContentView: React.FC<ContentViewProps> = ({ const ContentView: React.FC<ContentViewProps> = ({
@@ -23,6 +25,7 @@ const ContentView: React.FC<ContentViewProps> = ({
handleContentChange, handleContentChange,
handleSave, handleSave,
handleFileSelect, handleFileSelect,
files,
}) => { }) => {
const { currentWorkspace } = useWorkspace(); const { currentWorkspace } = useWorkspace();
if (!currentWorkspace) { if (!currentWorkspace) {
@@ -67,6 +70,7 @@ const ContentView: React.FC<ContentViewProps> = ({
handleContentChange={handleContentChange} handleContentChange={handleContentChange}
handleSave={handleSave} handleSave={handleSave}
selectedFile={selectedFile} selectedFile={selectedFile}
files={files}
/> />
) : ( ) : (
<MarkdownPreview content={content} handleFileSelect={handleFileSelect} /> <MarkdownPreview content={content} handleFileSelect={handleFileSelect} />

View File

@@ -1,17 +1,22 @@
import React, { useEffect, useRef } from 'react'; import React, { useEffect, useRef, useMemo } from 'react';
import { basicSetup } from 'codemirror'; import { basicSetup } from 'codemirror';
import { EditorState } from '@codemirror/state'; import { EditorState } from '@codemirror/state';
import { EditorView, keymap } from '@codemirror/view'; import { EditorView, keymap } from '@codemirror/view';
import { markdown } from '@codemirror/lang-markdown'; import { markdown } from '@codemirror/lang-markdown';
import { defaultKeymap } from '@codemirror/commands'; import { defaultKeymap } from '@codemirror/commands';
import { oneDark } from '@codemirror/theme-one-dark'; import { oneDark } from '@codemirror/theme-one-dark';
import { autocompletion } from '@codemirror/autocomplete';
import { useWorkspace } from '../../hooks/useWorkspace'; import { useWorkspace } from '../../hooks/useWorkspace';
import { createWikiLinkCompletions } from '../../utils/wikiLinkCompletion';
import { flattenFileTree } from '../../utils/fileHelpers';
import type { FileNode } from '../../types/models';
interface EditorProps { interface EditorProps {
content: string; content: string;
handleContentChange: (content: string) => void; handleContentChange: (content: string) => void;
handleSave: (filePath: string, content: string) => Promise<boolean>; handleSave: (filePath: string, content: string) => Promise<boolean>;
selectedFile: string; selectedFile: string;
files: FileNode[];
} }
const Editor: React.FC<EditorProps> = ({ const Editor: React.FC<EditorProps> = ({
@@ -19,11 +24,19 @@ const Editor: React.FC<EditorProps> = ({
handleContentChange, handleContentChange,
handleSave, handleSave,
selectedFile, selectedFile,
files,
}) => { }) => {
const { colorScheme } = useWorkspace(); const { colorScheme, currentWorkspace } = useWorkspace();
const editorRef = useRef<HTMLDivElement>(null); const editorRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<EditorView | null>(null); const viewRef = useRef<EditorView | null>(null);
// Flatten file tree for autocompletion, respecting showHiddenFiles setting
const showHiddenFiles = currentWorkspace?.showHiddenFiles || false;
const flatFiles = useMemo(
() => flattenFileTree(files, showHiddenFiles),
[files, showHiddenFiles]
);
useEffect(() => { useEffect(() => {
const handleEditorSave = (view: EditorView): boolean => { const handleEditorSave = (view: EditorView): boolean => {
void handleSave(selectedFile, view.state.doc.toString()); void handleSave(selectedFile, view.state.doc.toString());
@@ -71,6 +84,12 @@ const Editor: React.FC<EditorProps> = ({
}), }),
theme, theme,
colorScheme === 'dark' ? oneDark : [], colorScheme === 'dark' ? oneDark : [],
autocompletion({
override: [createWikiLinkCompletions(flatFiles)],
activateOnTyping: true,
maxRenderedOptions: 10,
closeOnBlur: true,
}),
], ],
}); });
@@ -87,7 +106,7 @@ const Editor: React.FC<EditorProps> = ({
}; };
// TODO: Refactor // TODO: Refactor
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [colorScheme, handleContentChange, handleSave, selectedFile]); }, [colorScheme, handleContentChange, handleSave, selectedFile, flatFiles]);
useEffect(() => { useEffect(() => {
if (viewRef.current && content !== viewRef.current.state.doc.toString()) { if (viewRef.current && content !== viewRef.current.state.doc.toString()) {

View File

@@ -6,7 +6,7 @@ import {
IconFolderOpen, IconFolderOpen,
IconUpload, IconUpload,
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import { Tooltip, Text, Box } from '@mantine/core'; import { Text, Box } from '@mantine/core';
import useResizeObserver from '@react-hook/resize-observer'; import useResizeObserver from '@react-hook/resize-observer';
import { useFileOperations } from '../../hooks/useFileOperations'; import { useFileOperations } from '../../hooks/useFileOperations';
import type { FileNode } from '@/types/models'; import type { FileNode } from '@/types/models';
@@ -53,13 +53,12 @@ function Node({
style, style,
dragHandle, dragHandle,
onNodeClick, onNodeClick,
...rest
}: { }: {
node: NodeApi<FileNode>; node: NodeApi<FileNode>;
style: React.CSSProperties; style: React.CSSProperties;
dragHandle?: React.Ref<HTMLDivElement>; dragHandle?: React.Ref<HTMLDivElement>;
onNodeClick?: (node: NodeApi<FileNode>) => void; onNodeClick?: (node: NodeApi<FileNode>) => void;
} & Record<string, unknown>) { }) {
const handleClick = () => { const handleClick = () => {
if (node.isInternal) { if (node.isInternal) {
node.toggle(); node.toggle();
@@ -69,37 +68,40 @@ function Node({
}; };
return ( return (
<Tooltip label={node.data.name} openDelay={500}> <div
<div ref={dragHandle} // This enables dragging for the node
ref={dragHandle} // This enables dragging for the node style={{
...style,
paddingLeft: `${node.level * 20}px`,
display: 'flex',
alignItems: 'center',
cursor: 'pointer',
whiteSpace: 'nowrap',
overflow: 'hidden',
// Add visual feedback when being dragged
opacity: node.state?.isDragging ? 0.5 : 1,
// Highlight when this node will receive the drop
backgroundColor: node.state?.willReceiveDrop
? 'rgba(0, 123, 255, 0.2)'
: 'transparent',
borderRadius: '4px',
}}
onClick={handleClick}
title={node.data.name}
>
<FileIcon node={node} />
<span
style={{ style={{
...style, marginLeft: '8px',
paddingLeft: `${node.level * 20}px`, fontSize: '14px',
display: 'flex',
alignItems: 'center',
cursor: 'pointer',
whiteSpace: 'nowrap',
overflow: 'hidden', overflow: 'hidden',
// Add visual feedback when being dragged textOverflow: 'ellipsis',
opacity: node.state?.isDragging ? 0.5 : 1, flexGrow: 1,
}} }}
onClick={handleClick}
{...rest}
> >
<FileIcon node={node} /> {node.data.name}
<span </span>
style={{ </div>
marginLeft: '8px',
fontSize: '14px',
overflow: 'hidden',
textOverflow: 'ellipsis',
flexGrow: 1,
}}
>
{node.data.name}
</span>
</div>
</Tooltip>
); );
} }
@@ -205,41 +207,46 @@ export const FileTree: React.FC<FileTreeProps> = ({
// External file drag and drop handlers // External file drag and drop handlers
const handleDragEnter = useCallback((e: React.DragEvent) => { const handleDragEnter = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
// Check if drag contains files (not internal tree nodes) // Check if drag contains files (not internal tree nodes)
if (e.dataTransfer.types.includes('Files')) { if (e.dataTransfer.types.includes('Files')) {
e.preventDefault();
e.stopPropagation();
setIsDragOver(true); setIsDragOver(true);
} }
}, []); }, []);
const handleDragLeave = useCallback((e: React.DragEvent) => { const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault(); // Only handle if it's an external file drag
e.stopPropagation(); if (e.dataTransfer.types.includes('Files')) {
e.preventDefault();
e.stopPropagation();
// Only hide overlay when leaving the container itself // Only hide overlay when leaving the container itself
if (e.currentTarget === e.target) { if (e.currentTarget === e.target) {
setIsDragOver(false); setIsDragOver(false);
}
} }
}, []); }, []);
const handleDragOver = useCallback((e: React.DragEvent) => { const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault(); // Only handle external file drags
e.stopPropagation(); if (e.dataTransfer.types.includes('Files')) {
// Set the drop effect to indicate this is a valid drop target e.preventDefault();
e.dataTransfer.dropEffect = 'copy'; e.stopPropagation();
// Set the drop effect to indicate this is a valid drop target
e.dataTransfer.dropEffect = 'copy';
}
}, []); }, []);
const handleDrop = useCallback( const handleDrop = useCallback(
(e: React.DragEvent) => { (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(false);
const { files } = e.dataTransfer; const { files } = e.dataTransfer;
// Only handle if it's an external file drop
if (files && files.length > 0) { if (files && files.length > 0) {
e.preventDefault();
e.stopPropagation();
setIsDragOver(false);
const uploadFiles = async () => { const uploadFiles = async () => {
try { try {
const success = await handleUpload(files); const success = await handleUpload(files);
@@ -305,7 +312,10 @@ export const FileTree: React.FC<FileTreeProps> = ({
height={size.height} height={size.height}
indent={24} indent={24}
rowHeight={28} rowHeight={28}
idAccessor="id"
onMove={handleTreeMove} onMove={handleTreeMove}
disableDrag={() => false}
disableDrop={() => false}
onActivate={(node) => { onActivate={(node) => {
const fileNode = node.data; const fileNode = node.data;
if (!node.isInternal) { if (!node.isInternal) {

View File

@@ -0,0 +1,266 @@
import React, { useRef, useLayoutEffect, useState } from 'react';
import { Box } from '@mantine/core';
import { Tree, type NodeApi } from 'react-arborist';
import {
IconFolder,
IconFolderOpen,
IconChevronRight,
} from '@tabler/icons-react';
import useResizeObserver from '@react-hook/resize-observer';
import { filterToFolders } from '../../utils/fileTreeUtils';
import type { FileNode } from '@/types/models';
interface FolderSelectorProps {
files: FileNode[];
selectedPath: string;
onSelect: (path: string) => void;
}
interface Size {
width: number;
height: number;
}
const useSize = (target: React.RefObject<HTMLElement>): Size | undefined => {
const [size, setSize] = useState<Size>();
useLayoutEffect(() => {
if (target.current) {
setSize(target.current.getBoundingClientRect());
}
}, [target]);
useResizeObserver(target, (entry) => setSize(entry.contentRect));
return size;
};
// Node component for rendering folders
function FolderNode({
node,
style,
selectedPath,
onSelect,
}: {
node: NodeApi<FileNode>;
style: React.CSSProperties;
selectedPath: string;
onSelect: (path: string) => void;
}) {
const isSelected = node.data.path === selectedPath;
const hasChildren = node.children && node.children.length > 0;
const handleClick = () => {
onSelect(node.data.path);
};
const handleChevronClick = (e: React.MouseEvent) => {
e.stopPropagation();
node.toggle();
};
return (
<div
style={{
...style,
paddingLeft: `${node.level * 16 + 8}px`,
paddingRight: '8px',
paddingTop: '4px',
paddingBottom: '4px',
display: 'flex',
alignItems: 'center',
cursor: 'pointer',
whiteSpace: 'nowrap',
overflow: 'hidden',
backgroundColor: isSelected
? 'var(--mantine-color-blue-filled)'
: 'transparent',
color: isSelected ? 'var(--mantine-color-white)' : 'inherit',
borderRadius: '4px',
transition: 'background-color 0.1s ease, color 0.1s ease',
}}
onClick={handleClick}
title={node.data.name}
onMouseEnter={(e) => {
if (!isSelected) {
e.currentTarget.style.backgroundColor =
'var(--mantine-color-default-hover)';
}
}}
onMouseLeave={(e) => {
if (!isSelected) {
e.currentTarget.style.backgroundColor = 'transparent';
}
}}
>
{/* Chevron for folders with children */}
{hasChildren && (
<IconChevronRight
size={14}
onClick={handleChevronClick}
style={{
marginRight: '4px',
transform: node.isOpen ? 'rotate(90deg)' : 'rotate(0deg)',
transition: 'transform 0.2s ease',
flexShrink: 0,
}}
/>
)}
{/* Spacer for items without chevron */}
{!hasChildren && <div style={{ width: '18px', flexShrink: 0 }} />}
{/* Folder icon */}
{node.isOpen ? (
<IconFolderOpen
size={16}
color={
isSelected
? 'var(--mantine-color-white)'
: 'var(--mantine-color-yellow-filled)'
}
style={{ flexShrink: 0 }}
/>
) : (
<IconFolder
size={16}
color={
isSelected
? 'var(--mantine-color-white)'
: 'var(--mantine-color-yellow-filled)'
}
style={{ flexShrink: 0 }}
/>
)}
{/* Name */}
<span
style={{
marginLeft: '8px',
fontSize: '14px',
overflow: 'hidden',
textOverflow: 'ellipsis',
flexGrow: 1,
}}
>
{node.data.name}
</span>
</div>
);
}
// Root node component
function RootNode({
isSelected,
onSelect,
}: {
isSelected: boolean;
onSelect: () => void;
}) {
return (
<div
style={{
paddingLeft: '8px',
paddingRight: '8px',
paddingTop: '4px',
paddingBottom: '4px',
display: 'flex',
alignItems: 'center',
cursor: 'pointer',
whiteSpace: 'nowrap',
overflow: 'hidden',
backgroundColor: isSelected
? 'var(--mantine-color-blue-filled)'
: 'transparent',
color: isSelected ? 'var(--mantine-color-white)' : 'inherit',
borderRadius: '4px',
transition: 'background-color 0.1s ease, color 0.1s ease',
marginBottom: '4px',
}}
onClick={onSelect}
onMouseEnter={(e) => {
if (!isSelected) {
e.currentTarget.style.backgroundColor =
'var(--mantine-color-default-hover)';
}
}}
onMouseLeave={(e) => {
if (!isSelected) {
e.currentTarget.style.backgroundColor = 'transparent';
}
}}
>
<div style={{ width: '18px', flexShrink: 0 }} />
<IconFolder
size={16}
color={
isSelected
? 'var(--mantine-color-white)'
: 'var(--mantine-color-yellow-filled)'
}
style={{ flexShrink: 0 }}
/>
<span
style={{
marginLeft: '8px',
fontSize: '14px',
overflow: 'hidden',
textOverflow: 'ellipsis',
flexGrow: 1,
}}
>
/ (root)
</span>
</div>
);
}
export const FolderSelector: React.FC<FolderSelectorProps> = ({
files,
selectedPath,
onSelect,
}) => {
const target = useRef<HTMLDivElement>(null);
const size = useSize(target);
// Filter to only folders
const folders = filterToFolders(files);
// Calculate tree height: root node (32px) + folders
const rootNodeHeight = 32;
const treeHeight = size ? size.height - rootNodeHeight : 0;
return (
<Box
ref={target}
style={{
maxHeight: '300px',
height: '300px',
overflowY: 'auto',
padding: '8px',
}}
>
{/* Root option */}
<RootNode isSelected={selectedPath === ''} onSelect={() => onSelect('')} />
{/* Folder tree */}
{size && folders.length > 0 && (
<Tree
data={folders}
openByDefault={false}
width={size.width - 16}
height={treeHeight}
indent={24}
rowHeight={28}
idAccessor="id"
disableDrag={() => true}
disableDrop={() => true}
>
{(props) => (
<FolderNode {...props} selectedPath={selectedPath} onSelect={onSelect} />
)}
</Tree>
)}
</Box>
);
};
export default FolderSelector;

View File

@@ -53,6 +53,7 @@ const Layout: React.FC = () => {
selectedFile={selectedFile} selectedFile={selectedFile}
handleFileSelect={handleFileSelect} handleFileSelect={handleFileSelect}
loadFileList={loadFileList} loadFileList={loadFileList}
files={files}
/> />
</Container> </Container>
</AppShell.Main> </AppShell.Main>

View File

@@ -131,6 +131,7 @@ describe('MainContent', () => {
selectedFile="docs/guide.md" selectedFile="docs/guide.md"
handleFileSelect={mockHandleFileSelect} handleFileSelect={mockHandleFileSelect}
loadFileList={mockLoadFileList} loadFileList={mockLoadFileList}
files={[]}
/> />
</TestWrapper> </TestWrapper>
); );
@@ -156,6 +157,7 @@ describe('MainContent', () => {
selectedFile="test.md" selectedFile="test.md"
handleFileSelect={mockHandleFileSelect} handleFileSelect={mockHandleFileSelect}
loadFileList={mockLoadFileList} loadFileList={mockLoadFileList}
files={[]}
/> />
</TestWrapper> </TestWrapper>
); );
@@ -172,6 +174,7 @@ describe('MainContent', () => {
selectedFile="test.md" selectedFile="test.md"
handleFileSelect={mockHandleFileSelect} handleFileSelect={mockHandleFileSelect}
loadFileList={mockLoadFileList} loadFileList={mockLoadFileList}
files={[]}
/> />
</TestWrapper> </TestWrapper>
); );
@@ -188,6 +191,7 @@ describe('MainContent', () => {
selectedFile={null} selectedFile={null}
handleFileSelect={mockHandleFileSelect} handleFileSelect={mockHandleFileSelect}
loadFileList={mockLoadFileList} loadFileList={mockLoadFileList}
files={[]}
/> />
</TestWrapper> </TestWrapper>
); );

View File

@@ -12,6 +12,7 @@ import { useFileContent } from '../../hooks/useFileContent';
import { useFileOperations } from '../../hooks/useFileOperations'; import { useFileOperations } from '../../hooks/useFileOperations';
import { useGitOperations } from '../../hooks/useGitOperations'; import { useGitOperations } from '../../hooks/useGitOperations';
import { useModalContext } from '../../contexts/ModalContext'; import { useModalContext } from '../../contexts/ModalContext';
import type { FileNode } from '../../types/models';
type ViewTab = 'source' | 'preview'; type ViewTab = 'source' | 'preview';
@@ -19,12 +20,14 @@ interface MainContentProps {
selectedFile: string | null; selectedFile: string | null;
handleFileSelect: (filePath: string | null) => Promise<void>; handleFileSelect: (filePath: string | null) => Promise<void>;
loadFileList: () => Promise<void>; loadFileList: () => Promise<void>;
files: FileNode[];
} }
const MainContent: React.FC<MainContentProps> = ({ const MainContent: React.FC<MainContentProps> = ({
selectedFile, selectedFile,
handleFileSelect, handleFileSelect,
loadFileList, loadFileList,
files,
}) => { }) => {
const [activeTab, setActiveTab] = useState<ViewTab>('source'); const [activeTab, setActiveTab] = useState<ViewTab>('source');
const { const {
@@ -161,6 +164,7 @@ const MainContent: React.FC<MainContentProps> = ({
handleContentChange={handleContentChange} handleContentChange={handleContentChange}
handleSave={handleSaveFile} handleSave={handleSaveFile}
handleFileSelect={handleFileSelect} handleFileSelect={handleFileSelect}
files={files}
/> />
</Box> </Box>
<CreateFileModal onCreateFile={handleCreateFile} /> <CreateFileModal onCreateFile={handleCreateFile} />

View File

@@ -29,6 +29,31 @@ vi.mock('../../../contexts/ModalContext', () => ({
useModalContext: () => mockModalContext, useModalContext: () => mockModalContext,
})); }));
// Mock useFileList hook
const mockLoadFileList = vi.fn();
const mockFiles = [
{
id: '1',
name: 'docs',
path: 'docs',
children: [
{
id: '2',
name: 'guides',
path: 'docs/guides',
children: [],
},
],
},
];
vi.mock('../../../hooks/useFileList', () => ({
useFileList: () => ({
files: mockFiles,
loadFileList: mockLoadFileList,
}),
}));
// Helper wrapper component for testing // Helper wrapper component for testing
const TestWrapper = ({ children }: { children: React.ReactNode }) => ( const TestWrapper = ({ children }: { children: React.ReactNode }) => (
<MantineProvider defaultColorScheme="light">{children}</MantineProvider> <MantineProvider defaultColorScheme="light">{children}</MantineProvider>
@@ -47,6 +72,8 @@ describe('CreateFileModal', () => {
mockOnCreateFile.mockReset(); mockOnCreateFile.mockReset();
mockOnCreateFile.mockResolvedValue(undefined); mockOnCreateFile.mockResolvedValue(undefined);
mockModalContext.setNewFileModalVisible.mockClear(); mockModalContext.setNewFileModalVisible.mockClear();
mockLoadFileList.mockClear();
mockLoadFileList.mockResolvedValue(undefined);
}); });
describe('Modal Visibility and Content', () => { describe('Modal Visibility and Content', () => {

View File

@@ -1,6 +1,9 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Modal, TextInput, Button, Group, Box } from '@mantine/core'; import { Modal, TextInput, Button, Group, Box, Popover, ActionIcon, Text } from '@mantine/core';
import { IconFolderOpen } from '@tabler/icons-react';
import { useModalContext } from '../../../contexts/ModalContext'; import { useModalContext } from '../../../contexts/ModalContext';
import { useFileList } from '../../../hooks/useFileList';
import { FolderSelector } from '../../files/FolderSelector';
interface CreateFileModalProps { interface CreateFileModalProps {
onCreateFile: (fileName: string) => Promise<void>; onCreateFile: (fileName: string) => Promise<void>;
@@ -8,16 +11,49 @@ interface CreateFileModalProps {
const CreateFileModal: React.FC<CreateFileModalProps> = ({ onCreateFile }) => { const CreateFileModal: React.FC<CreateFileModalProps> = ({ onCreateFile }) => {
const [fileName, setFileName] = useState<string>(''); const [fileName, setFileName] = useState<string>('');
const [selectedFolder, setSelectedFolder] = useState<string>('');
const [popoverOpened, setPopoverOpened] = useState<boolean>(false);
const { newFileModalVisible, setNewFileModalVisible } = useModalContext(); const { newFileModalVisible, setNewFileModalVisible } = useModalContext();
const { files, loadFileList } = useFileList();
const handleSubmit = async (): Promise<void> => { const handleSubmit = async (): Promise<void> => {
if (fileName) { if (fileName) {
await onCreateFile(fileName.trim()); const fullPath = selectedFolder
? `${selectedFolder}/${fileName.trim()}`
: fileName.trim();
await onCreateFile(fullPath);
setFileName(''); setFileName('');
setSelectedFolder('');
setNewFileModalVisible(false); setNewFileModalVisible(false);
} }
}; };
const handleClose = () => {
setFileName('');
setSelectedFolder('');
setNewFileModalVisible(false);
};
const handleFolderSelect = (path: string) => {
setSelectedFolder(path);
setPopoverOpened(false);
};
// Load files when modal opens
React.useEffect(() => {
if (newFileModalVisible) {
void loadFileList();
}
}, [newFileModalVisible, loadFileList]);
// Generate full path preview
const fullPathPreview = selectedFolder
? `${selectedFolder}/${fileName || 'filename'}`
: fileName || 'filename';
// Display text for location input
const locationDisplay = selectedFolder || '/ (root)';
const handleKeyDown = (event: React.KeyboardEvent): void => { const handleKeyDown = (event: React.KeyboardEvent): void => {
if (event.key === 'Enter' && !event.shiftKey) { if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault(); event.preventDefault();
@@ -28,27 +64,82 @@ const CreateFileModal: React.FC<CreateFileModalProps> = ({ onCreateFile }) => {
return ( return (
<Modal <Modal
opened={newFileModalVisible} opened={newFileModalVisible}
onClose={() => setNewFileModalVisible(false)} onClose={handleClose}
title="Create New File" title="Create New File"
centered centered
size="sm" size="sm"
> >
<Box maw={400} mx="auto"> <Box maw={400} mx="auto">
{/* Location input with folder picker */}
<Popover
opened={popoverOpened}
onChange={setPopoverOpened}
position="bottom-start"
width="target"
>
<Popover.Target>
<TextInput
label="Location"
type="text"
placeholder="Select folder"
data-testid="location-input"
value={locationDisplay}
readOnly
mb="md"
w="100%"
rightSection={
<ActionIcon
variant="subtle"
onClick={() => setPopoverOpened((o) => !o)}
data-testid="folder-picker-button"
>
<IconFolderOpen size={18} />
</ActionIcon>
}
styles={{
input: {
cursor: 'pointer',
},
}}
onClick={() => setPopoverOpened(true)}
/>
</Popover.Target>
<Popover.Dropdown>
<FolderSelector
files={files}
selectedPath={selectedFolder}
onSelect={handleFolderSelect}
/>
</Popover.Dropdown>
</Popover>
{/* File name input */}
<TextInput <TextInput
label="File Name" label="File Name"
type="text" type="text"
placeholder="Enter file name" placeholder="example.md"
data-testid="file-name-input" data-testid="file-name-input"
value={fileName} value={fileName}
onChange={(event) => setFileName(event.currentTarget.value)} onChange={(event) => setFileName(event.currentTarget.value)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
mb="md" mb="xs"
w="100%" w="100%"
/> />
{/* Hint text */}
<Text size="xs" c="dimmed" mb="xs">
Tip: Use / to create nested folders (e.g., folder/subfolder/file.md)
</Text>
{/* Full path preview */}
<Text size="sm" c="dimmed" mb="md">
Full path: {fullPathPreview}
</Text>
<Group justify="flex-end" mt="md"> <Group justify="flex-end" mt="md">
<Button <Button
variant="default" variant="default"
onClick={() => setNewFileModalVisible(false)} onClick={handleClose}
data-testid="cancel-create-file-button" data-testid="cancel-create-file-button"
> >
Cancel Cancel

View File

@@ -8,7 +8,7 @@ import {
import React from 'react'; import React from 'react';
import { MantineProvider } from '@mantine/core'; import { MantineProvider } from '@mantine/core';
import DeleteUserModal from './DeleteUserModal'; import DeleteUserModal from './DeleteUserModal';
import { UserRole, type User } from '@/types/models'; import { UserRole, Theme, type User } from '@/types/models';
// Mock notifications // Mock notifications
vi.mock('@mantine/notifications', () => ({ vi.mock('@mantine/notifications', () => ({
@@ -36,6 +36,7 @@ describe('DeleteUserModal', () => {
email: 'test@example.com', email: 'test@example.com',
displayName: 'Test User', displayName: 'Test User',
role: UserRole.Editor, role: UserRole.Editor,
theme: Theme.Dark,
createdAt: '2024-01-01T00:00:00Z', createdAt: '2024-01-01T00:00:00Z',
lastWorkspaceId: 1, lastWorkspaceId: 1,
}; };

View File

@@ -8,7 +8,7 @@ import {
import React from 'react'; import React from 'react';
import { MantineProvider } from '@mantine/core'; import { MantineProvider } from '@mantine/core';
import EditUserModal from './EditUserModal'; import EditUserModal from './EditUserModal';
import { UserRole, type User } from '@/types/models'; import { UserRole, Theme, type User } from '@/types/models';
// Mock notifications // Mock notifications
vi.mock('@mantine/notifications', () => ({ vi.mock('@mantine/notifications', () => ({
@@ -36,6 +36,7 @@ describe('EditUserModal', () => {
email: 'test@example.com', email: 'test@example.com',
displayName: 'Test User', displayName: 'Test User',
role: UserRole.Editor, role: UserRole.Editor,
theme: Theme.Dark,
createdAt: '2024-01-01T00:00:00Z', createdAt: '2024-01-01T00:00:00Z',
lastWorkspaceId: 1, lastWorkspaceId: 1,
}; };
@@ -187,6 +188,7 @@ describe('EditUserModal', () => {
email: 'newuser@example.com', email: 'newuser@example.com',
displayName: 'New User', displayName: 'New User',
role: UserRole.Admin, role: UserRole.Admin,
theme: Theme.Dark,
}; };
rerender( rerender(

View File

@@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
import { fireEvent, waitFor } from '@testing-library/react'; import { fireEvent, waitFor } from '@testing-library/react';
import { render } from '../../test/utils'; import { render } from '../../test/utils';
import UserMenu from './UserMenu'; import UserMenu from './UserMenu';
import { UserRole } from '../../types/models'; import { UserRole, Theme } from '../../types/models';
// Mock the contexts // Mock the contexts
vi.mock('../../contexts/AuthContext', () => ({ vi.mock('../../contexts/AuthContext', () => ({
@@ -37,6 +37,7 @@ describe('UserMenu', () => {
email: 'test@example.com', email: 'test@example.com',
displayName: 'Test User', displayName: 'Test User',
role: UserRole.Editor, role: UserRole.Editor,
theme: Theme.Dark,
createdAt: '2024-01-01T00:00:00Z', createdAt: '2024-01-01T00:00:00Z',
lastWorkspaceId: 1, lastWorkspaceId: 1,
}; };
@@ -53,6 +54,7 @@ describe('UserMenu', () => {
login: vi.fn(), login: vi.fn(),
refreshToken: vi.fn(), refreshToken: vi.fn(),
refreshUser: vi.fn(), refreshUser: vi.fn(),
updateProfile: vi.fn(),
}); });
}); });
@@ -84,6 +86,7 @@ describe('UserMenu', () => {
login: vi.fn(), login: vi.fn(),
refreshToken: vi.fn(), refreshToken: vi.fn(),
refreshUser: vi.fn(), refreshUser: vi.fn(),
updateProfile: vi.fn(),
}); });
const { getByLabelText, getByText } = render( const { getByLabelText, getByText } = render(
@@ -145,6 +148,7 @@ describe('UserMenu', () => {
id: mockUser.id, id: mockUser.id,
email: mockUser.email, email: mockUser.email,
role: mockUser.role, role: mockUser.role,
theme: mockUser.theme,
createdAt: mockUser.createdAt, createdAt: mockUser.createdAt,
lastWorkspaceId: mockUser.lastWorkspaceId, lastWorkspaceId: mockUser.lastWorkspaceId,
}; };
@@ -157,6 +161,7 @@ describe('UserMenu', () => {
login: vi.fn(), login: vi.fn(),
refreshToken: vi.fn(), refreshToken: vi.fn(),
refreshUser: vi.fn(), refreshUser: vi.fn(),
updateProfile: vi.fn(),
}); });
const { getByLabelText, getByText } = render( const { getByLabelText, getByText } = render(

View File

@@ -89,6 +89,7 @@ const AccountSettings: React.FC<AccountSettingsProps> = ({
email: user.email, email: user.email,
currentPassword: '', currentPassword: '',
newPassword: '', newPassword: '',
theme: user.theme,
}; };
dispatch({ dispatch({
type: SettingsActionType.INIT_SETTINGS, type: SettingsActionType.INIT_SETTINGS,
@@ -107,6 +108,13 @@ const AccountSettings: React.FC<AccountSettingsProps> = ({
}); });
}; };
const handleThemeChange = (theme: string): void => {
dispatch({
type: SettingsActionType.UPDATE_LOCAL_SETTINGS,
payload: { theme } as UserProfileSettings,
});
};
const handleSubmit = async (): Promise<void> => { const handleSubmit = async (): Promise<void> => {
const updates: UserProfileSettings = {}; const updates: UserProfileSettings = {};
const needsPasswordConfirmation = const needsPasswordConfirmation =
@@ -117,6 +125,14 @@ const AccountSettings: React.FC<AccountSettingsProps> = ({
updates.displayName = state.localSettings.displayName || ''; updates.displayName = state.localSettings.displayName || '';
} }
// Add theme if changed
if (
state.localSettings.theme &&
state.localSettings.theme !== state.initialSettings.theme
) {
updates.theme = state.localSettings.theme;
}
// Handle password change // Handle password change
if (state.localSettings.newPassword) { if (state.localSettings.newPassword) {
if (!state.localSettings.currentPassword) { if (!state.localSettings.currentPassword) {
@@ -216,6 +232,7 @@ const AccountSettings: React.FC<AccountSettingsProps> = ({
<ProfileSettings <ProfileSettings
settings={state.localSettings} settings={state.localSettings}
onInputChange={handleInputChange} onInputChange={handleInputChange}
onThemeChange={handleThemeChange}
/> />
</Accordion.Panel> </Accordion.Panel>
</Accordion.Item> </Accordion.Item>

View File

@@ -4,6 +4,25 @@ import React from 'react';
import { MantineProvider } from '@mantine/core'; import { MantineProvider } from '@mantine/core';
import ProfileSettings from './ProfileSettings'; import ProfileSettings from './ProfileSettings';
import type { UserProfileSettings } from '@/types/models'; import type { UserProfileSettings } from '@/types/models';
import { Theme, UserRole, type User } from '@/types/models';
// Mock user for AuthContext
const mockUser: User = {
id: 1,
email: 'test@example.com',
displayName: 'Test User',
role: UserRole.Editor,
theme: Theme.Dark,
createdAt: '2024-01-01T00:00:00Z',
lastWorkspaceId: 1,
};
// Mock the auth context
vi.mock('../../../contexts/AuthContext', () => ({
useAuth: () => ({
user: mockUser,
}),
}));
// Helper wrapper component for testing // Helper wrapper component for testing
const TestWrapper = ({ children }: { children: React.ReactNode }) => ( const TestWrapper = ({ children }: { children: React.ReactNode }) => (

View File

@@ -1,36 +1,66 @@
import React from 'react'; import React from 'react';
import { Box, Stack, TextInput } from '@mantine/core'; import { Box, Stack, TextInput, Group, Text, Switch } from '@mantine/core';
import type { UserProfileSettings } from '@/types/models'; import { useAuth } from '@/contexts/AuthContext';
import { Theme, type UserProfileSettings } from '@/types/models';
interface ProfileSettingsProps { interface ProfileSettingsProps {
settings: UserProfileSettings; settings: UserProfileSettings;
onInputChange: (key: keyof UserProfileSettings, value: string) => void; onInputChange: (key: keyof UserProfileSettings, value: string) => void;
onThemeChange?: (theme: Theme) => void;
} }
const ProfileSettings: React.FC<ProfileSettingsProps> = ({ const ProfileSettings: React.FC<ProfileSettingsProps> = ({
settings, settings,
onInputChange, onInputChange,
}) => ( onThemeChange,
<Box> }) => {
<Stack gap="md"> const { user } = useAuth();
<TextInput const currentTheme = settings.theme || user?.theme || Theme.Dark;
label="Display Name"
type="text" const handleThemeToggle = () => {
value={settings.displayName || ''} const newTheme = currentTheme === Theme.Dark ? Theme.Light : Theme.Dark;
onChange={(e) => onInputChange('displayName', e.currentTarget.value)} if (onThemeChange) {
placeholder="Enter display name" onThemeChange(newTheme);
data-testid="display-name-input" }
/> };
<TextInput
label="Email" return (
type="email" <Box>
value={settings.email || ''} <Stack gap="md">
onChange={(e) => onInputChange('email', e.currentTarget.value)} <TextInput
placeholder="Enter email" label="Display Name"
data-testid="email-input" type="text"
/> value={settings.displayName || ''}
</Stack> onChange={(e) => onInputChange('displayName', e.currentTarget.value)}
</Box> placeholder="Enter display name"
); data-testid="display-name-input"
/>
<TextInput
label="Email"
type="email"
value={settings.email || ''}
onChange={(e) => onInputChange('email', e.currentTarget.value)}
placeholder="Enter email"
data-testid="email-input"
/>
<Box mb="md">
<Group justify="space-between" align="center">
<div>
<Text size="sm">Default Dark Mode</Text>
<Text size="xs" c="dimmed">
Sets the default theme for new workspaces
</Text>
</div>
<Switch
checked={currentTheme === Theme.Dark}
onChange={handleThemeToggle}
data-testid="theme-toggle"
/>
</Group>
</Box>
</Stack>
</Box>
);
};
export default ProfileSettings; export default ProfileSettings;

View File

@@ -3,7 +3,7 @@ import { render as rtlRender, screen, fireEvent } from '@testing-library/react';
import React from 'react'; import React from 'react';
import { MantineProvider } from '@mantine/core'; import { MantineProvider } from '@mantine/core';
import AdminDashboard from './AdminDashboard'; import AdminDashboard from './AdminDashboard';
import { UserRole, type User } from '@/types/models'; import { UserRole, Theme, type User } from '@/types/models';
// Mock the auth context // Mock the auth context
const mockCurrentUser: User = { const mockCurrentUser: User = {
@@ -11,6 +11,7 @@ const mockCurrentUser: User = {
email: 'admin@example.com', email: 'admin@example.com',
displayName: 'Admin User', displayName: 'Admin User',
role: UserRole.Admin, role: UserRole.Admin,
theme: Theme.Dark,
createdAt: '2024-01-01T00:00:00Z', createdAt: '2024-01-01T00:00:00Z',
lastWorkspaceId: 1, lastWorkspaceId: 1,
}; };

View File

@@ -8,7 +8,7 @@ import {
import React from 'react'; import React from 'react';
import { MantineProvider } from '@mantine/core'; import { MantineProvider } from '@mantine/core';
import AdminUsersTab from './AdminUsersTab'; import AdminUsersTab from './AdminUsersTab';
import { UserRole, type User } from '@/types/models'; import { UserRole, Theme, type User } from '@/types/models';
// Mock the user admin hook // Mock the user admin hook
const mockCreate = vi.fn(); const mockCreate = vi.fn();
@@ -123,6 +123,7 @@ describe('AdminUsersTab', () => {
email: 'admin@example.com', email: 'admin@example.com',
displayName: 'Admin User', displayName: 'Admin User',
role: UserRole.Admin, role: UserRole.Admin,
theme: Theme.Dark,
createdAt: '2024-01-01T00:00:00Z', createdAt: '2024-01-01T00:00:00Z',
lastWorkspaceId: 1, lastWorkspaceId: 1,
}; };
@@ -134,6 +135,7 @@ describe('AdminUsersTab', () => {
email: 'editor@example.com', email: 'editor@example.com',
displayName: 'Editor User', displayName: 'Editor User',
role: UserRole.Editor, role: UserRole.Editor,
theme: Theme.Dark,
createdAt: '2024-01-15T00:00:00Z', createdAt: '2024-01-15T00:00:00Z',
lastWorkspaceId: 2, lastWorkspaceId: 2,
}, },
@@ -142,6 +144,7 @@ describe('AdminUsersTab', () => {
email: 'viewer@example.com', email: 'viewer@example.com',
displayName: 'Viewer User', displayName: 'Viewer User',
role: UserRole.Viewer, role: UserRole.Viewer,
theme: Theme.Dark,
createdAt: '2024-02-01T00:00:00Z', createdAt: '2024-02-01T00:00:00Z',
lastWorkspaceId: 3, lastWorkspaceId: 3,
}, },

View File

@@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { renderHook, act, waitFor } from '@testing-library/react'; import { renderHook, act, waitFor } from '@testing-library/react';
import React from 'react'; import React from 'react';
import { AuthProvider, useAuth } from './AuthContext'; import { AuthProvider, useAuth } from './AuthContext';
import { UserRole, type User } from '@/types/models'; import { UserRole, Theme, type User } from '@/types/models';
// Set up mocks before imports are used // Set up mocks before imports are used
vi.mock('@/api/auth', () => { vi.mock('@/api/auth', () => {
@@ -42,6 +42,7 @@ const mockUser: User = {
email: 'test@example.com', email: 'test@example.com',
displayName: 'Test User', displayName: 'Test User',
role: UserRole.Editor, role: UserRole.Editor,
theme: Theme.Dark,
createdAt: '2024-01-01T00:00:00Z', createdAt: '2024-01-01T00:00:00Z',
lastWorkspaceId: 1, lastWorkspaceId: 1,
}; };

View File

@@ -12,7 +12,8 @@ import {
refreshToken as apiRefreshToken, refreshToken as apiRefreshToken,
getCurrentUser, getCurrentUser,
} from '@/api/auth'; } from '@/api/auth';
import type { User } from '@/types/models'; import { updateProfile as apiUpdateProfile } from '@/api/user';
import type { User, UserProfileSettings } from '@/types/models';
interface AuthContextType { interface AuthContextType {
user: User | null; user: User | null;
@@ -22,6 +23,7 @@ interface AuthContextType {
logout: () => Promise<void>; logout: () => Promise<void>;
refreshToken: () => Promise<boolean>; refreshToken: () => Promise<boolean>;
refreshUser: () => Promise<void>; refreshUser: () => Promise<void>;
updateProfile: (updates: UserProfileSettings) => Promise<User>;
} }
const AuthContext = createContext<AuthContextType | null>(null); const AuthContext = createContext<AuthContextType | null>(null);
@@ -109,6 +111,31 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
} }
}, []); }, []);
const updateProfile = useCallback(
async (updates: UserProfileSettings): Promise<User> => {
try {
const updatedUser = await apiUpdateProfile(updates);
setUser(updatedUser);
notifications.show({
title: 'Success',
message: 'Profile updated successfully',
color: 'green',
});
return updatedUser;
} catch (error) {
console.error('Failed to update profile:', error);
notifications.show({
title: 'Error',
message:
error instanceof Error ? error.message : 'Failed to update profile',
color: 'red',
});
throw error;
}
},
[]
);
const value: AuthContextType = { const value: AuthContextType = {
user, user,
loading, loading,
@@ -117,6 +144,7 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
logout, logout,
refreshToken, refreshToken,
refreshUser, refreshUser,
updateProfile,
}; };
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>; return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;

View File

@@ -4,6 +4,7 @@ import { useAdminData } from './useAdminData';
import * as adminApi from '@/api/admin'; import * as adminApi from '@/api/admin';
import { import {
UserRole, UserRole,
Theme,
type SystemStats, type SystemStats,
type User, type User,
type WorkspaceStats, type WorkspaceStats,
@@ -35,6 +36,7 @@ const mockUsers: User[] = [
email: 'admin@example.com', email: 'admin@example.com',
displayName: 'Admin User', displayName: 'Admin User',
role: UserRole.Admin, role: UserRole.Admin,
theme: Theme.Dark,
createdAt: '2024-01-01T00:00:00Z', createdAt: '2024-01-01T00:00:00Z',
lastWorkspaceId: 1, lastWorkspaceId: 1,
}, },
@@ -43,6 +45,7 @@ const mockUsers: User[] = [
email: 'editor@example.com', email: 'editor@example.com',
displayName: 'Editor User', displayName: 'Editor User',
role: UserRole.Editor, role: UserRole.Editor,
theme: Theme.Dark,
createdAt: '2024-01-02T00:00:00Z', createdAt: '2024-01-02T00:00:00Z',
lastWorkspaceId: 2, lastWorkspaceId: 2,
}, },

View File

@@ -34,6 +34,9 @@ describe('useFileNavigation', () => {
id: 1, id: 1,
name: 'test-workspace', name: 'test-workspace',
}; };
// Default mock implementations
mockLastOpenedFile.loadLastOpenedFile.mockResolvedValue(null);
mockLastOpenedFile.saveLastOpenedFile.mockResolvedValue(undefined);
}); });
afterEach(() => { afterEach(() => {

View File

@@ -122,7 +122,8 @@ export const useFileOperations = (): UseFileOperationsResult => {
if (!currentWorkspace) return false; if (!currentWorkspace) return false;
try { try {
await uploadFile(currentWorkspace.name, targetPath || '', files); // Default to '.' (root directory) if no target path is provided
await uploadFile(currentWorkspace.name, targetPath || '.', files);
notifications.show({ notifications.show({
title: 'Success', title: 'Success',

View File

@@ -3,7 +3,7 @@ import { renderHook, act } from '@testing-library/react';
import { useProfileSettings } from './useProfileSettings'; import { useProfileSettings } from './useProfileSettings';
import * as userApi from '@/api/user'; import * as userApi from '@/api/user';
import type { UpdateProfileRequest } from '@/types/api'; import type { UpdateProfileRequest } from '@/types/api';
import { UserRole, type User } from '@/types/models'; import { UserRole, Theme, type User } from '@/types/models';
// Mock dependencies // Mock dependencies
vi.mock('@/api/user'); vi.mock('@/api/user');
@@ -22,6 +22,7 @@ const mockUser: User = {
email: 'test@example.com', email: 'test@example.com',
displayName: 'Test User', displayName: 'Test User',
role: UserRole.Editor, role: UserRole.Editor,
theme: Theme.Dark,
createdAt: '2024-01-01T00:00:00Z', createdAt: '2024-01-01T00:00:00Z',
lastWorkspaceId: 1, lastWorkspaceId: 1,
}; };

View File

@@ -3,7 +3,7 @@ import { renderHook, act } from '@testing-library/react';
import { useUserAdmin } from './useUserAdmin'; import { useUserAdmin } from './useUserAdmin';
import * as adminApi from '@/api/admin'; import * as adminApi from '@/api/admin';
import type { CreateUserRequest, UpdateUserRequest } from '@/types/api'; import type { CreateUserRequest, UpdateUserRequest } from '@/types/api';
import { UserRole, type User } from '@/types/models'; import { UserRole, Theme, type User } from '@/types/models';
// Mock dependencies // Mock dependencies
vi.mock('@/api/admin'); vi.mock('@/api/admin');
@@ -35,6 +35,7 @@ const mockUsers: User[] = [
email: 'admin@example.com', email: 'admin@example.com',
displayName: 'Admin User', displayName: 'Admin User',
role: UserRole.Admin, role: UserRole.Admin,
theme: Theme.Dark,
createdAt: '2024-01-01T00:00:00Z', createdAt: '2024-01-01T00:00:00Z',
lastWorkspaceId: 1, lastWorkspaceId: 1,
}, },
@@ -43,6 +44,7 @@ const mockUsers: User[] = [
email: 'editor@example.com', email: 'editor@example.com',
displayName: 'Editor User', displayName: 'Editor User',
role: UserRole.Editor, role: UserRole.Editor,
theme: Theme.Dark,
createdAt: '2024-01-02T00:00:00Z', createdAt: '2024-01-02T00:00:00Z',
lastWorkspaceId: 1, lastWorkspaceId: 1,
}, },
@@ -112,6 +114,7 @@ describe('useUserAdmin', () => {
email: 'newuser@example.com', email: 'newuser@example.com',
displayName: 'New User', displayName: 'New User',
role: UserRole.Viewer, role: UserRole.Viewer,
theme: Theme.Dark,
createdAt: '2024-01-03T00:00:00Z', createdAt: '2024-01-03T00:00:00Z',
lastWorkspaceId: 1, lastWorkspaceId: 1,
}; };
@@ -124,6 +127,7 @@ describe('useUserAdmin', () => {
displayName: 'New User', displayName: 'New User',
password: 'password123', password: 'password123',
role: UserRole.Viewer, role: UserRole.Viewer,
theme: Theme.Dark,
}; };
let createResult: boolean | undefined; let createResult: boolean | undefined;
@@ -152,6 +156,7 @@ describe('useUserAdmin', () => {
displayName: 'Test User', displayName: 'Test User',
password: 'password123', password: 'password123',
role: UserRole.Editor, role: UserRole.Editor,
theme: Theme.Dark,
}; };
let createResult: boolean | undefined; let createResult: boolean | undefined;
@@ -179,6 +184,7 @@ describe('useUserAdmin', () => {
displayName: 'Test User', displayName: 'Test User',
password: 'password123', password: 'password123',
role: UserRole.Editor, role: UserRole.Editor,
theme: Theme.Dark,
}; };
let createResult: boolean | undefined; let createResult: boolean | undefined;
@@ -204,6 +210,7 @@ describe('useUserAdmin', () => {
email: user.email, email: user.email,
displayName: 'Updated Editor', displayName: 'Updated Editor',
role: user.role, role: user.role,
theme: user.theme,
createdAt: user.createdAt, createdAt: user.createdAt,
lastWorkspaceId: user.lastWorkspaceId, lastWorkspaceId: user.lastWorkspaceId,
}; };
@@ -238,6 +245,7 @@ describe('useUserAdmin', () => {
email: 'newemail@example.com', email: 'newemail@example.com',
displayName: user.displayName || '', displayName: user.displayName || '',
role: UserRole.Admin, role: UserRole.Admin,
theme: Theme.Dark,
createdAt: user.createdAt, createdAt: user.createdAt,
lastWorkspaceId: user.lastWorkspaceId, lastWorkspaceId: user.lastWorkspaceId,
}; };
@@ -248,6 +256,7 @@ describe('useUserAdmin', () => {
const updateRequest: UpdateUserRequest = { const updateRequest: UpdateUserRequest = {
email: 'newemail@example.com', email: 'newemail@example.com',
role: UserRole.Admin, role: UserRole.Admin,
theme: Theme.Dark,
}; };
let updateResult: boolean | undefined; let updateResult: boolean | undefined;
@@ -436,6 +445,7 @@ describe('useUserAdmin', () => {
displayName: 'Test', displayName: 'Test',
password: 'pass', password: 'pass',
role: UserRole.Viewer, role: UserRole.Viewer,
theme: Theme.Dark,
}); });
}); });
@@ -474,6 +484,7 @@ describe('useUserAdmin', () => {
displayName: 'Test', displayName: 'Test',
password: 'pass', password: 'pass',
role: UserRole.Viewer, role: UserRole.Viewer,
theme: Theme.Dark,
}); });
}); });
@@ -500,6 +511,7 @@ describe('useUserAdmin', () => {
email: 'user1@example.com', email: 'user1@example.com',
displayName: 'User 1', displayName: 'User 1',
role: UserRole.Viewer, role: UserRole.Viewer,
theme: Theme.Dark,
createdAt: '2024-01-03T00:00:00Z', createdAt: '2024-01-03T00:00:00Z',
lastWorkspaceId: 1, lastWorkspaceId: 1,
}) })
@@ -508,6 +520,7 @@ describe('useUserAdmin', () => {
email: 'user2@example.com', email: 'user2@example.com',
displayName: 'User 2', displayName: 'User 2',
role: UserRole.Editor, role: UserRole.Editor,
theme: Theme.Dark,
createdAt: '2024-01-04T00:00:00Z', createdAt: '2024-01-04T00:00:00Z',
lastWorkspaceId: 1, lastWorkspaceId: 1,
}); });
@@ -520,12 +533,14 @@ describe('useUserAdmin', () => {
displayName: 'User 1', displayName: 'User 1',
password: 'pass1', password: 'pass1',
role: UserRole.Viewer, role: UserRole.Viewer,
theme: Theme.Dark,
}, },
{ {
email: 'user2@example.com', email: 'user2@example.com',
displayName: 'User 2', displayName: 'User 2',
password: 'pass2', password: 'pass2',
role: UserRole.Editor, role: UserRole.Editor,
theme: Theme.Dark,
}, },
]; ];
@@ -555,12 +570,14 @@ describe('useUserAdmin', () => {
displayName: 'Success User', displayName: 'Success User',
password: 'pass1', password: 'pass1',
role: UserRole.Viewer, role: UserRole.Viewer,
theme: Theme.Dark,
}, },
{ {
email: 'fail@example.com', email: 'fail@example.com',
displayName: 'Fail User', displayName: 'Fail User',
password: 'pass2', password: 'pass2',
role: UserRole.Editor, role: UserRole.Editor,
theme: Theme.Dark,
}, },
]; ];

View File

@@ -1,5 +1,26 @@
import '@testing-library/jest-dom'; import '@testing-library/jest-dom';
import { vi } from 'vitest'; import { vi, beforeAll, afterAll } from 'vitest';
// Suppress console errors during tests
const originalConsoleError = console.error;
beforeAll(() => {
console.error = (...args: any[]) => {
// Suppress specific expected errors during tests
const errorString = args.join(' ');
if (
errorString.includes('Failed to initialize auth') ||
errorString.includes('Failed to save last opened file') ||
errorString.includes('Failed to load last opened file')
) {
return;
}
originalConsoleError(...args);
};
});
afterAll(() => {
console.error = originalConsoleError;
});
// Mock window.API_BASE_URL // Mock window.API_BASE_URL
Object.defineProperty(window, 'API_BASE_URL', { Object.defineProperty(window, 'API_BASE_URL', {
@@ -23,8 +44,8 @@ Object.defineProperty(window, 'matchMedia', {
}); });
// Mock ResizeObserver - sometimes needed for Mantine components // Mock ResizeObserver - sometimes needed for Mantine components
global.ResizeObserver = vi.fn().mockImplementation(() => ({ global.ResizeObserver = class ResizeObserver {
observe: vi.fn(), observe = vi.fn();
unobserve: vi.fn(), unobserve = vi.fn();
disconnect: vi.fn(), disconnect = vi.fn();
})); };

View File

@@ -9,7 +9,7 @@ import {
type SaveFileResponse, type SaveFileResponse,
type UploadFilesResponse, type UploadFilesResponse,
} from './api'; } from './api';
import { UserRole, type User } from './models'; import { UserRole, Theme, type User } from './models';
// Mock user data for testing // Mock user data for testing
const mockUser: User = { const mockUser: User = {
@@ -17,6 +17,7 @@ const mockUser: User = {
email: 'test@example.com', email: 'test@example.com',
displayName: 'Test User', displayName: 'Test User',
role: UserRole.Editor, role: UserRole.Editor,
theme: Theme.Dark,
createdAt: '2024-01-01T00:00:00Z', createdAt: '2024-01-01T00:00:00Z',
lastWorkspaceId: 1, lastWorkspaceId: 1,
}; };

View File

@@ -1,4 +1,4 @@
import { isUser, type User, type UserRole } from './models'; import { isUser, type User, type UserRole, type Theme } from './models';
declare global { declare global {
interface Window { interface Window {
@@ -55,6 +55,7 @@ export interface CreateUserRequest {
displayName: string; displayName: string;
password: string; password: string;
role: UserRole; role: UserRole;
theme?: Theme;
} }
// UpdateUserRequest holds the request fields for updating a user // UpdateUserRequest holds the request fields for updating a user
@@ -63,6 +64,7 @@ export interface UpdateUserRequest {
displayName?: string; displayName?: string;
password?: string; password?: string;
role?: UserRole; role?: UserRole;
theme?: Theme;
} }
export interface LookupResponse { export interface LookupResponse {
@@ -126,6 +128,7 @@ export interface UpdateProfileRequest {
email?: string; email?: string;
currentPassword?: string; currentPassword?: string;
newPassword?: string; newPassword?: string;
theme?: Theme;
} }
// DeleteAccountRequest represents a user account deletion request // DeleteAccountRequest represents a user account deletion request

View File

@@ -63,6 +63,7 @@ describe('Models Type Guards', () => {
email: 'test@example.com', email: 'test@example.com',
displayName: 'Test User', displayName: 'Test User',
role: UserRole.Editor, role: UserRole.Editor,
theme: Theme.Dark,
createdAt: '2024-01-01T00:00:00Z', createdAt: '2024-01-01T00:00:00Z',
lastWorkspaceId: 1, lastWorkspaceId: 1,
}; };
@@ -76,6 +77,7 @@ describe('Models Type Guards', () => {
id: 1, id: 1,
email: 'test@example.com', email: 'test@example.com',
role: UserRole.Editor, role: UserRole.Editor,
theme: Theme.Dark,
createdAt: '2024-01-01T00:00:00Z', createdAt: '2024-01-01T00:00:00Z',
lastWorkspaceId: 1, lastWorkspaceId: 1,
}; };
@@ -186,6 +188,7 @@ describe('Models Type Guards', () => {
id: 1, id: 1,
email: 'test@example.com', email: 'test@example.com',
role: UserRole.Editor, role: UserRole.Editor,
theme: Theme.Dark,
createdAt: '2024-01-01T00:00:00Z', createdAt: '2024-01-01T00:00:00Z',
lastWorkspaceId: 1, lastWorkspaceId: 1,
__proto__: { malicious: true }, __proto__: { malicious: true },
@@ -771,6 +774,7 @@ describe('Models Type Guards', () => {
id: 1, id: 1,
email: 'test@example.com', email: 'test@example.com',
role: UserRole.Editor, role: UserRole.Editor,
theme: Theme.Dark,
createdAt: '2024-01-01T00:00:00Z', createdAt: '2024-01-01T00:00:00Z',
lastWorkspaceId: 1, lastWorkspaceId: 1,
}; };
@@ -804,6 +808,7 @@ describe('Models Type Guards', () => {
id: 1, id: 1,
email: 'test@example.com', email: 'test@example.com',
role: UserRole.Editor, role: UserRole.Editor,
theme: Theme.Dark,
createdAt: '2024-01-01T00:00:00Z', createdAt: '2024-01-01T00:00:00Z',
lastWorkspaceId: 1, lastWorkspaceId: 1,
}); });
@@ -852,6 +857,7 @@ describe('Models Type Guards', () => {
id: 1, id: 1,
email: longString, email: longString,
role: UserRole.Editor, role: UserRole.Editor,
theme: Theme.Dark,
createdAt: '2024-01-01T00:00:00Z', createdAt: '2024-01-01T00:00:00Z',
lastWorkspaceId: 1, lastWorkspaceId: 1,
}; };

View File

@@ -8,6 +8,7 @@ export interface User {
email: string; email: string;
displayName?: string; displayName?: string;
role: UserRole; role: UserRole;
theme: Theme;
createdAt: string; createdAt: string;
lastWorkspaceId: number; lastWorkspaceId: number;
} }
@@ -28,6 +29,9 @@ export function isUser(value: unknown): value is User {
: true) && : true) &&
'role' in value && 'role' in value &&
isUserRole((value as User).role) && isUserRole((value as User).role) &&
'theme' in value &&
typeof (value as User).theme === 'string' &&
Object.values(Theme).includes((value as User).theme) &&
'createdAt' in value && 'createdAt' in value &&
typeof (value as User).createdAt === 'string' && typeof (value as User).createdAt === 'string' &&
'lastWorkspaceId' in value && 'lastWorkspaceId' in value &&
@@ -171,7 +175,7 @@ export interface DefaultFile {
export const DEFAULT_FILE: DefaultFile = { export const DEFAULT_FILE: DefaultFile = {
name: 'New File.md', name: 'New File.md',
path: 'New File.md', path: 'New File.md',
content: '# Welcome to NovaMD\n\nStart editing here!', content: '# Welcome to Lemma\n\nStart editing here!',
}; };
export interface FileNode { export interface FileNode {
@@ -309,6 +313,7 @@ export interface UserProfileSettings {
email?: string; email?: string;
currentPassword?: string; currentPassword?: string;
newPassword?: string; newPassword?: string;
theme?: Theme;
} }
export interface ProfileSettingsState { export interface ProfileSettingsState {

View File

@@ -1,5 +1,17 @@
import { API_BASE_URL } from '@/types/api'; import { API_BASE_URL } from '@/types/api';
import { IMAGE_EXTENSIONS } from '@/types/models'; import { IMAGE_EXTENSIONS, type FileNode } from '@/types/models';
/**
* Represents a flattened file for searching and autocompletion
*/
export interface FlatFile {
name: string; // "meeting-notes.md"
path: string; // "work/2024/meeting-notes.md"
displayPath: string; // "work/2024/meeting-notes"
nameWithoutExt: string; // "meeting-notes"
parentFolder: string; // "work/2024"
isImage: boolean;
}
/** /**
* Checks if the given file path has an image extension. * Checks if the given file path has an image extension.
@@ -15,3 +27,65 @@ export const getFileUrl = (workspaceName: string, filePath: string) => {
workspaceName workspaceName
)}/files/content?file_path=${encodeURIComponent(filePath)}`; )}/files/content?file_path=${encodeURIComponent(filePath)}`;
}; };
/**
* Recursively flattens FileNode tree into searchable array
* Precomputes display strings and metadata for performance
*
* @param nodes - Array of FileNode from the file tree
* @param showHiddenFiles - Whether to include hidden files (files/folders starting with .)
* @returns Array of FlatFile objects ready for searching
*/
export function flattenFileTree(nodes: FileNode[], showHiddenFiles = false): FlatFile[] {
const result: FlatFile[] = [];
function traverse(node: FileNode) {
// Skip hidden files and folders if showHiddenFiles is false
// Hidden files/folders are those that start with a dot (.)
if (!showHiddenFiles && node.name.startsWith('.')) {
return;
}
// Only process files, not folders (folders have children)
if (!node.children) {
const name = node.name;
const path = node.path;
const isImage = isImageFile(path);
// Remove extension for display (except for images)
let nameWithoutExt = name;
let displayPath = path;
if (name.endsWith('.md')) {
nameWithoutExt = name.slice(0, -3);
displayPath = path.slice(0, -3);
}
// Get parent folder path
const lastSlashIndex = path.lastIndexOf('/');
const parentFolder = lastSlashIndex > 0 ? path.slice(0, lastSlashIndex) : '';
result.push({
name,
path,
displayPath,
nameWithoutExt,
parentFolder,
isImage,
});
}
// Recursively process children
if (node.children) {
for (const child of node.children) {
traverse(child);
}
}
}
for (const node of nodes) {
traverse(node);
}
return result;
}

View File

@@ -0,0 +1,44 @@
import type { FileNode } from '@/types/models';
/**
* Recursively filter tree to only include folders
* @param nodes - Array of FileNode objects
* @returns New tree structure with only folder nodes
*/
export const filterToFolders = (nodes: FileNode[]): FileNode[] => {
return nodes
.filter((node) => node.children !== undefined)
.map((node) => {
const filtered: FileNode = {
id: node.id,
name: node.name,
path: node.path,
};
if (node.children) {
filtered.children = filterToFolders(node.children);
}
return filtered;
});
};
/**
* Find a specific folder node by its path
* @param nodes - Array of FileNode objects
* @param path - Path to search for
* @returns The found FileNode or null
*/
export const findFolderByPath = (
nodes: FileNode[],
path: string
): FileNode | null => {
for (const node of nodes) {
if (node.path === path && node.children !== undefined) {
return node;
}
if (node.children) {
const found = findFolderByPath(node.children, path);
if (found) return found;
}
}
return null;
};

129
app/src/utils/fuzzyMatch.ts Normal file
View File

@@ -0,0 +1,129 @@
/**
* Result of a fuzzy match operation
*/
export interface MatchResult {
matched: boolean;
score: number; // Higher is better
matchedIndices: number[]; // For highlighting
}
/**
* Scoring weights for match quality
*/
const SCORING = {
consecutiveMatch: 15,
wordBoundaryMatch: 10,
camelCaseMatch: 10,
firstCharMatch: 15,
gapPenalty: -1,
} as const;
/**
* Performs fuzzy matching between query and target string
*
* Algorithm:
* - Sequential character matching (order matters)
* - Bonus for consecutive matches
* - Bonus for word boundary matches
* - Bonus for camelCase matches
* - Case-insensitive by default
*
* Example:
* query: "mtno"
* target: "meeting-notes"
* → matches: [0, 4, 8, 9], score: 85
*
* @param query - The search string
* @param target - The string to search in
* @returns MatchResult with matched status, score, and matched indices
*/
export function fuzzyMatch(query: string, target: string): MatchResult {
if (!query) {
return { matched: true, score: 0, matchedIndices: [] };
}
const queryLower = query.toLowerCase();
const targetLower = target.toLowerCase();
const matchedIndices: number[] = [];
let score = 0;
let queryIndex = 0;
let previousMatchIndex = -1;
// Try to match all query characters in order
for (let targetIndex = 0; targetIndex < targetLower.length; targetIndex++) {
if (queryIndex >= queryLower.length) {
break;
}
if (queryLower[queryIndex] === targetLower[targetIndex]) {
matchedIndices.push(targetIndex);
// Bonus for first character match
if (targetIndex === 0) {
score += SCORING.firstCharMatch;
}
// Bonus for consecutive matches
if (previousMatchIndex === targetIndex - 1) {
score += SCORING.consecutiveMatch;
} else if (previousMatchIndex >= 0) {
// Penalty for gaps
const gap = targetIndex - previousMatchIndex - 1;
score += gap * SCORING.gapPenalty;
}
// Bonus for word boundary matches
if (isWordBoundary(target, targetIndex)) {
score += SCORING.wordBoundaryMatch;
}
// Bonus for camelCase matches
if (isCamelCaseMatch(target, targetIndex)) {
score += SCORING.camelCaseMatch;
}
previousMatchIndex = targetIndex;
queryIndex++;
}
}
// All query characters must be matched
const matched = queryIndex === queryLower.length;
if (!matched) {
return { matched: false, score: 0, matchedIndices: [] };
}
// Boost score for matches with higher character coverage
const coverage = matchedIndices.length / target.length;
score += coverage * 50;
return { matched, score, matchedIndices };
}
/**
* Checks if a character at the given index is at a word boundary
* Word boundaries are: start of string, after space, after dash, after slash
*/
function isWordBoundary(str: string, index: number): boolean {
if (index === 0) return true;
const prevChar = str[index - 1];
return prevChar === ' ' || prevChar === '-' || prevChar === '/' || prevChar === '_';
}
/**
* Checks if a character at the given index is a camelCase boundary
* (lowercase followed by uppercase)
*/
function isCamelCaseMatch(str: string, index: number): boolean {
if (index === 0 || index >= str.length) return false;
const currentChar = str[index];
const prevChar = str[index - 1];
if (!currentChar || !prevChar) return false;
return (
currentChar === currentChar.toUpperCase() &&
currentChar !== currentChar.toLowerCase() &&
prevChar === prevChar.toLowerCase()
);
}

View File

@@ -240,17 +240,13 @@ export function remarkWikiLinks(workspaceName: string) {
continue; continue;
} }
const lookupFileName: string = match.isImage // If the filename contains a path separator, treat it as a full path
? match.fileName // This handles wikilinks with paths like [[folder/subfolder/file]]
: addMarkdownExtension(match.fileName); let filePath: string;
if (match.fileName.includes('/')) {
// It's already a full path - use it directly
filePath = match.isImage ? match.fileName : addMarkdownExtension(match.fileName);
const paths: string[] = await lookupFileByName(
workspaceName,
lookupFileName
);
if (paths && paths.length > 0 && paths[0]) {
const filePath: string = paths[0];
if (match.isImage) { if (match.isImage) {
newNodes.push( newNodes.push(
createImageNode(workspaceName, filePath, match.displayText) createImageNode(workspaceName, filePath, match.displayText)
@@ -266,9 +262,37 @@ export function remarkWikiLinks(workspaceName: string) {
); );
} }
} else { } else {
newNodes.push( // It's just a filename - look it up to find the full path
createNotFoundLink(match.fileName, match.displayText, baseUrl) const lookupFileName: string = match.isImage
? match.fileName
: addMarkdownExtension(match.fileName);
const paths: string[] = await lookupFileByName(
workspaceName,
lookupFileName
); );
if (paths && paths.length > 0 && paths[0]) {
filePath = paths[0];
if (match.isImage) {
newNodes.push(
createImageNode(workspaceName, filePath, match.displayText)
);
} else {
newNodes.push(
createFileLink(
filePath,
match.displayText,
match.heading,
baseUrl
)
);
}
} else {
newNodes.push(
createNotFoundLink(match.fileName, match.displayText, baseUrl)
);
}
} }
} catch (error) { } catch (error) {
console.debug('File lookup failed:', match.fileName, error); console.debug('File lookup failed:', match.fileName, error);

View File

@@ -0,0 +1,239 @@
import type {
CompletionContext,
CompletionResult,
Completion,
} from '@codemirror/autocomplete';
import type { FlatFile } from './fileHelpers';
import { fuzzyMatch } from './fuzzyMatch';
/**
* Wiki link context detection result
*/
interface WikiLinkContext {
isWikiLink: boolean;
isImage: boolean; // true if ![[
query: string; // partial text after [[
from: number; // cursor position to replace from
to: number; // cursor position to replace to
}
/**
* Creates CodeMirror autocompletion source for wiki links
*
* @param files - Flattened file list from workspace
* @returns CompletionSource function
*/
export function createWikiLinkCompletions(
files: FlatFile[]
): (context: CompletionContext) => CompletionResult | null {
return (context: CompletionContext): CompletionResult | null => {
const wikiContext = detectWikiLinkContext(context);
if (!wikiContext.isWikiLink) {
return null;
}
// Filter and rank files based on query
const rankedFiles = filterAndRankFiles(
wikiContext.query,
files,
wikiContext.isImage,
50
);
if (rankedFiles.length === 0) {
return null;
}
// Convert to completion options
const options = rankedFiles.map((file) => formatCompletion(file));
return {
from: wikiContext.from,
to: wikiContext.to,
options,
// Don't set filter or validFor - let CodeMirror re-trigger our completion
// source on every keystroke so we can re-filter with fuzzy matching
};
};
}
/**
* Detects if cursor is inside a wiki link and extracts context
*
* Detection logic:
* 1. Search backwards from cursor for [[ or ![[
* 2. Ensure no closing ]] between opener and cursor
* 3. Extract partial query (text after [[ and before cursor)
* 4. Determine if image link (![[) or regular ([[)
*
* Examples:
* "[[meeti|ng" → { isWikiLink: true, query: "meeti", ... }
* "![[img|" → { isWikiLink: true, isImage: true, query: "img", ... }
* "regular text|" → { isWikiLink: false }
*/
function detectWikiLinkContext(context: CompletionContext): WikiLinkContext {
const { state, pos } = context;
const line = state.doc.lineAt(pos);
const textBefore = state.sliceDoc(line.from, pos);
// Look for [[ or ![[
const imageWikiLinkMatch = textBefore.lastIndexOf('![[');
const regularWikiLinkMatch = textBefore.lastIndexOf('[[');
// Determine which one is closer to cursor
let isImage = false;
let openerIndex = -1;
if (imageWikiLinkMatch > regularWikiLinkMatch) {
isImage = true;
openerIndex = imageWikiLinkMatch;
} else if (regularWikiLinkMatch >= 0) {
openerIndex = regularWikiLinkMatch;
}
// If no opener found, not in a wiki link
if (openerIndex < 0) {
return {
isWikiLink: false,
isImage: false,
query: '',
from: pos,
to: pos,
};
}
// Calculate the absolute position of the opener in the document
const openerPos = line.from + openerIndex;
const openerLength = isImage ? 3 : 2; // ![[ or [[
const queryStartPos = openerPos + openerLength;
// Check if there's a closing ]] between opener and cursor
const textAfterOpener = textBefore.slice(openerIndex);
const closingIndex = textAfterOpener.indexOf(']]');
if (closingIndex >= 0 && closingIndex < textAfterOpener.length - 2) {
// Found ]] before cursor, so we're not inside a wiki link
return {
isWikiLink: false,
isImage: false,
query: '',
from: pos,
to: pos,
};
}
// Extract the query (text between [[ and cursor)
const query = state.sliceDoc(queryStartPos, pos);
return {
isWikiLink: true,
isImage,
query,
from: queryStartPos,
to: pos,
};
}
/**
* Filters files and ranks by relevance
*
* Ranking priority:
* 1. File type match (images for ![[, markdown for [[)
* 2. Fuzzy match score
* 3. Exact filename match > path component match
* 4. Shorter paths (prefer root over deeply nested)
* 5. Alphabetical for ties
*
* @param query - User's partial input
* @param files - All available files
* @param isImage - Whether to filter for images
* @param maxResults - Limit returned results (default: 50)
*/
function filterAndRankFiles(
query: string,
files: FlatFile[],
isImage: boolean,
maxResults = 50
): FlatFile[] {
// If query is empty, show all matching file types
if (!query) {
const filtered = files.filter((f) => f.isImage === isImage);
return filtered.slice(0, maxResults);
}
interface ScoredFile {
file: FlatFile;
score: number;
nameScore: number;
pathScore: number;
}
const scored: ScoredFile[] = [];
for (const file of files) {
// Filter by file type
if (file.isImage !== isImage) {
continue;
}
// Try matching against different fields
const nameMatch = fuzzyMatch(query, file.nameWithoutExt);
const pathMatch = fuzzyMatch(query, file.displayPath);
// Use the best match
if (nameMatch.matched || pathMatch.matched) {
// Prefer name matches over path matches
const nameScore = nameMatch.matched ? nameMatch.score : 0;
const pathScore = pathMatch.matched ? pathMatch.score : 0;
// Name matches get higher priority
const totalScore = nameScore * 2 + pathScore;
// Penalize deeply nested files slightly
const depth = file.path.split('/').length;
const depthPenalty = depth * 0.5;
scored.push({
file,
score: totalScore - depthPenalty,
nameScore,
pathScore,
});
}
}
// Sort by score (descending), then alphabetically
scored.sort((a, b) => {
if (b.score !== a.score) {
return b.score - a.score;
}
return a.file.displayPath.localeCompare(b.file.displayPath);
});
// Return top results
return scored.slice(0, maxResults).map((s) => s.file);
}
/**
* Converts FlatFile to CodeMirror Completion object
*
* Format rules:
* - Markdown files: show path without .md extension
* - Images: show full path with extension
* - Display includes path relative to workspace root
* - Apply text: full path (no extension for .md)
*
* Example outputs:
* work/2024/meeting-notes (for .md)
* assets/screenshot.png (for image)
*/
function formatCompletion(file: FlatFile): Completion {
return {
label: file.displayPath,
apply: file.displayPath,
type: file.isImage ? 'image' : 'file',
detail: file.parentFolder || '/',
boost: 0,
};
}

View File

@@ -11,7 +11,10 @@ export default defineConfig(({ mode }) => ({
react({ react({
include: ['**/*.tsx', '**/*.ts', '**/*.jsx', '**/*.js'], include: ['**/*.tsx', '**/*.ts', '**/*.jsx', '**/*.js'],
}), }),
compression(), compression({
threshold: 1024, // Only compress files > 1KB
deleteOriginalAssets: false, // Keep original files
}),
], ],
root: 'src', root: 'src',

View File

@@ -1,5 +1,5 @@
/// <reference types="vitest" /> /// <reference types="vitest" />
import { defineConfig } from 'vite'; import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react'; import react from '@vitejs/plugin-react';
import path from 'path'; import path from 'path';

View File

@@ -118,6 +118,7 @@ func setupAdminUser(database db.Database, storageManager storage.Manager, cfg *C
DisplayName: "Admin", DisplayName: "Admin",
PasswordHash: string(hashedPassword), PasswordHash: string(hashedPassword),
Role: models.RoleAdmin, Role: models.RoleAdmin,
Theme: "dark", // default theme
} }
createdUser, err := database.CreateUser(adminUser) createdUser, err := database.CreateUser(adminUser)
@@ -132,7 +133,8 @@ func setupAdminUser(database db.Database, storageManager storage.Manager, cfg *C
logging.Info("admin user setup completed", logging.Info("admin user setup completed",
"userId", createdUser.ID, "userId", createdUser.ID,
"workspaceId", createdUser.LastWorkspaceID) "workspaceId", createdUser.LastWorkspaceID,
"theme", createdUser.Theme)
return nil return nil
} }

View File

@@ -7,6 +7,7 @@ CREATE TABLE IF NOT EXISTS users (
display_name TEXT, display_name TEXT,
password_hash TEXT NOT NULL, password_hash TEXT NOT NULL,
role TEXT NOT NULL CHECK(role IN ('admin', 'editor', 'viewer')), role TEXT NOT NULL CHECK(role IN ('admin', 'editor', 'viewer')),
theme TEXT NOT NULL DEFAULT 'dark' CHECK(theme IN ('light', 'dark')),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_workspace_id INTEGER last_workspace_id INTEGER
); );
@@ -19,7 +20,7 @@ CREATE TABLE IF NOT EXISTS workspaces (
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_opened_file_path TEXT, last_opened_file_path TEXT,
-- Settings fields -- Settings fields
theme TEXT NOT NULL DEFAULT 'light' CHECK(theme IN ('light', 'dark')), theme TEXT NOT NULL DEFAULT 'dark' CHECK(theme IN ('light', 'dark')),
auto_save BOOLEAN NOT NULL DEFAULT FALSE, auto_save BOOLEAN NOT NULL DEFAULT FALSE,
git_enabled BOOLEAN NOT NULL DEFAULT FALSE, git_enabled BOOLEAN NOT NULL DEFAULT FALSE,
git_url TEXT, git_url TEXT,

View File

@@ -6,6 +6,7 @@ CREATE TABLE IF NOT EXISTS users (
display_name TEXT, display_name TEXT,
password_hash TEXT NOT NULL, password_hash TEXT NOT NULL,
role TEXT NOT NULL CHECK(role IN ('admin', 'editor', 'viewer')), role TEXT NOT NULL CHECK(role IN ('admin', 'editor', 'viewer')),
theme TEXT NOT NULL DEFAULT 'dark' CHECK(theme IN ('light', 'dark')),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_workspace_id INTEGER last_workspace_id INTEGER
); );
@@ -18,7 +19,7 @@ CREATE TABLE IF NOT EXISTS workspaces (
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_opened_file_path TEXT, last_opened_file_path TEXT,
-- Settings fields -- Settings fields
theme TEXT NOT NULL DEFAULT 'light' CHECK(theme IN ('light', 'dark')), theme TEXT NOT NULL DEFAULT 'dark' CHECK(theme IN ('light', 'dark')),
auto_save BOOLEAN NOT NULL DEFAULT 0, auto_save BOOLEAN NOT NULL DEFAULT 0,
git_enabled BOOLEAN NOT NULL DEFAULT 0, git_enabled BOOLEAN NOT NULL DEFAULT 0,
git_url TEXT, git_url TEXT,

View File

@@ -29,6 +29,7 @@ func TestSessionOperations(t *testing.T) {
DisplayName: "Test User", DisplayName: "Test User",
PasswordHash: "hash", PasswordHash: "hash",
Role: "editor", Role: "editor",
Theme: "dark",
}) })
if err != nil { if err != nil {
t.Fatalf("failed to create test user: %v", err) t.Fatalf("failed to create test user: %v", err)

View File

@@ -156,6 +156,7 @@ func TestStructQueries(t *testing.T) {
DisplayName: "Struct Query Test", DisplayName: "Struct Query Test",
PasswordHash: "hashed_password", PasswordHash: "hashed_password",
Role: models.RoleEditor, Role: models.RoleEditor,
Theme: "dark",
} }
t.Run("InsertStructQuery", func(t *testing.T) { t.Run("InsertStructQuery", func(t *testing.T) {
@@ -243,6 +244,7 @@ func TestStructQueries(t *testing.T) {
DisplayName: "Struct Query Test 2", DisplayName: "Struct Query Test 2",
PasswordHash: "hashed_password2", PasswordHash: "hashed_password2",
Role: models.RoleViewer, Role: models.RoleViewer,
Theme: "light",
} }
createdUser2, err := database.CreateUser(secondUser) createdUser2, err := database.CreateUser(secondUser)
@@ -437,6 +439,7 @@ func TestEncryptedFields(t *testing.T) {
DisplayName: "Encryption Test", DisplayName: "Encryption Test",
PasswordHash: "hash", PasswordHash: "hash",
Role: models.RoleEditor, Role: models.RoleEditor,
Theme: "dark",
}) })
if err != nil { if err != nil {
t.Fatalf("Failed to create test user: %v", err) t.Fatalf("Failed to create test user: %v", err)

View File

@@ -31,12 +31,14 @@ func TestSystemOperations(t *testing.T) {
DisplayName: "User 1", DisplayName: "User 1",
PasswordHash: "hash1", PasswordHash: "hash1",
Role: "editor", Role: "editor",
Theme: "dark",
}, },
{ {
Email: "user2@test.com", Email: "user2@test.com",
DisplayName: "User 2", DisplayName: "User 2",
PasswordHash: "hash2", PasswordHash: "hash2",
Role: "viewer", Role: "viewer",
Theme: "light",
}, },
} }

View File

@@ -34,6 +34,7 @@ func TestUserOperations(t *testing.T) {
DisplayName: "Test User", DisplayName: "Test User",
PasswordHash: "hashed_password", PasswordHash: "hashed_password",
Role: models.RoleEditor, Role: models.RoleEditor,
Theme: "dark",
}, },
wantErr: false, wantErr: false,
}, },
@@ -44,6 +45,7 @@ func TestUserOperations(t *testing.T) {
DisplayName: "Another User", DisplayName: "Another User",
PasswordHash: "different_hash", PasswordHash: "different_hash",
Role: models.RoleViewer, Role: models.RoleViewer,
Theme: "light",
}, },
wantErr: true, wantErr: true,
errContains: "UNIQUE constraint failed", errContains: "UNIQUE constraint failed",
@@ -108,6 +110,7 @@ func TestUserOperations(t *testing.T) {
DisplayName: "Get By ID User", DisplayName: "Get By ID User",
PasswordHash: "hash", PasswordHash: "hash",
Role: models.RoleEditor, Role: models.RoleEditor,
Theme: "dark",
}) })
if err != nil { if err != nil {
t.Fatalf("failed to create test user: %v", err) t.Fatalf("failed to create test user: %v", err)
@@ -159,6 +162,7 @@ func TestUserOperations(t *testing.T) {
DisplayName: "Get By Email User", DisplayName: "Get By Email User",
PasswordHash: "hash", PasswordHash: "hash",
Role: models.RoleEditor, Role: models.RoleEditor,
Theme: "dark",
}) })
if err != nil { if err != nil {
t.Fatalf("failed to create test user: %v", err) t.Fatalf("failed to create test user: %v", err)
@@ -210,6 +214,7 @@ func TestUserOperations(t *testing.T) {
DisplayName: "Original Name", DisplayName: "Original Name",
PasswordHash: "original_hash", PasswordHash: "original_hash",
Role: models.RoleEditor, Role: models.RoleEditor,
Theme: "dark",
}) })
if err != nil { if err != nil {
t.Fatalf("failed to create test user: %v", err) t.Fatalf("failed to create test user: %v", err)
@@ -249,12 +254,14 @@ func TestUserOperations(t *testing.T) {
DisplayName: "User One", DisplayName: "User One",
PasswordHash: "hash1", PasswordHash: "hash1",
Role: models.RoleEditor, Role: models.RoleEditor,
Theme: "dark",
}, },
{ {
Email: "user2@example.com", Email: "user2@example.com",
DisplayName: "User Two", DisplayName: "User Two",
PasswordHash: "hash2", PasswordHash: "hash2",
Role: models.RoleViewer, Role: models.RoleViewer,
Theme: "light",
}, },
} }
@@ -305,6 +312,7 @@ func TestUserOperations(t *testing.T) {
DisplayName: "Workspace User", DisplayName: "Workspace User",
PasswordHash: "hash", PasswordHash: "hash",
Role: models.RoleEditor, Role: models.RoleEditor,
Theme: "dark",
}) })
if err != nil { if err != nil {
t.Fatalf("failed to create test user: %v", err) t.Fatalf("failed to create test user: %v", err)
@@ -343,6 +351,7 @@ func TestUserOperations(t *testing.T) {
DisplayName: "Delete User", DisplayName: "Delete User",
PasswordHash: "hash", PasswordHash: "hash",
Role: models.RoleEditor, Role: models.RoleEditor,
Theme: "dark",
}) })
if err != nil { if err != nil {
t.Fatalf("failed to create test user: %v", err) t.Fatalf("failed to create test user: %v", err)
@@ -377,18 +386,21 @@ func TestUserOperations(t *testing.T) {
DisplayName: "Admin One", DisplayName: "Admin One",
PasswordHash: "hash1", PasswordHash: "hash1",
Role: models.RoleAdmin, Role: models.RoleAdmin,
Theme: "dark",
}, },
{ {
Email: "admin2@example.com", Email: "admin2@example.com",
DisplayName: "Admin Two", DisplayName: "Admin Two",
PasswordHash: "hash2", PasswordHash: "hash2",
Role: models.RoleAdmin, Role: models.RoleAdmin,
Theme: "light",
}, },
{ {
Email: "editor@example.com", Email: "editor@example.com",
DisplayName: "Editor", DisplayName: "Editor",
PasswordHash: "hash3", PasswordHash: "hash3",
Role: models.RoleEditor, Role: models.RoleEditor,
Theme: "dark",
}, },
} }

View File

@@ -26,6 +26,7 @@ func TestWorkspaceOperations(t *testing.T) {
DisplayName: "Test User", DisplayName: "Test User",
PasswordHash: "hash", PasswordHash: "hash",
Role: models.RoleEditor, Role: models.RoleEditor,
Theme: "dark",
}) })
if err != nil { if err != nil {
t.Fatalf("failed to create test user: %v", err) t.Fatalf("failed to create test user: %v", err)

View File

@@ -22,6 +22,7 @@ type CreateUserRequest struct {
DisplayName string `json:"displayName"` DisplayName string `json:"displayName"`
Password string `json:"password"` Password string `json:"password"`
Role models.UserRole `json:"role"` Role models.UserRole `json:"role"`
Theme string `json:"theme,omitempty"`
} }
// UpdateUserRequest holds the request fields for updating a user // UpdateUserRequest holds the request fields for updating a user
@@ -30,6 +31,7 @@ type UpdateUserRequest struct {
DisplayName string `json:"displayName,omitempty"` DisplayName string `json:"displayName,omitempty"`
Password string `json:"password,omitempty"` Password string `json:"password,omitempty"`
Role models.UserRole `json:"role,omitempty"` Role models.UserRole `json:"role,omitempty"`
Theme string `json:"theme,omitempty"`
} }
// WorkspaceStats holds workspace statistics // WorkspaceStats holds workspace statistics
@@ -164,11 +166,24 @@ func (h *Handler) AdminCreateUser() http.HandlerFunc {
return return
} }
// Handle theme with validation and default
theme := req.Theme
if theme == "" {
theme = "dark" // Default theme
} else if theme != "light" && theme != "dark" {
// Invalid theme, fallback to dark
log.Debug("invalid theme value in user creation, falling back to dark",
"theme", theme,
)
theme = "dark"
}
user := &models.User{ user := &models.User{
Email: req.Email, Email: req.Email,
DisplayName: req.DisplayName, DisplayName: req.DisplayName,
PasswordHash: string(hashedPassword), PasswordHash: string(hashedPassword),
Role: req.Role, Role: req.Role,
Theme: theme,
} }
insertedUser, err := h.DB.CreateUser(user) insertedUser, err := h.DB.CreateUser(user)
@@ -196,6 +211,7 @@ func (h *Handler) AdminCreateUser() http.HandlerFunc {
"newUserID", insertedUser.ID, "newUserID", insertedUser.ID,
"email", insertedUser.Email, "email", insertedUser.Email,
"role", insertedUser.Role, "role", insertedUser.Role,
"theme", insertedUser.Theme,
) )
respondJSON(w, insertedUser) respondJSON(w, insertedUser)
} }
@@ -322,6 +338,17 @@ func (h *Handler) AdminUpdateUser() http.HandlerFunc {
user.Role = req.Role user.Role = req.Role
updates["role"] = req.Role updates["role"] = req.Role
} }
if req.Theme != "" {
// Validate theme value, fallback to "dark" if invalid
if req.Theme != "light" && req.Theme != "dark" {
log.Debug("invalid theme value, falling back to dark",
"theme", req.Theme,
)
req.Theme = "dark"
}
user.Theme = req.Theme
updates["theme"] = req.Theme
}
if req.Password != "" { if req.Password != "" {
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil { if err != nil {

View File

@@ -410,7 +410,8 @@ func (h *Handler) UploadFile() http.HandlerFunc {
} }
}() }()
filePath := decodedPath + "/" + formFile.Filename // Use filepath.Join to properly construct the path
filePath := filepath.Join(decodedPath, formFile.Filename)
content, err := io.ReadAll(file) content, err := io.ReadAll(file)
if err != nil { if err != nil {

View File

@@ -352,15 +352,6 @@ func testFileHandlers(t *testing.T, dbConfig DatabaseConfig) {
assert.Equal(t, http.StatusBadRequest, rr.Code) assert.Equal(t, http.StatusBadRequest, rr.Code)
}) })
t.Run("upload with missing file_path parameter", func(t *testing.T) {
fileName := "test.txt"
fileContent := "test content"
files := map[string]string{fileName: fileContent}
rr := h.makeUploadRequest(t, baseURL+"/upload", files, h.RegularTestUser)
assert.Equal(t, http.StatusBadRequest, rr.Code)
})
t.Run("upload with invalid file_path", func(t *testing.T) { t.Run("upload with invalid file_path", func(t *testing.T) {
fileName := "test.txt" fileName := "test.txt"
fileContent := "test content" fileContent := "test content"

View File

@@ -213,6 +213,7 @@ func (h *testHarness) createTestUser(t *testing.T, email, password string, role
DisplayName: "Test User", DisplayName: "Test User",
PasswordHash: string(hashedPassword), PasswordHash: string(hashedPassword),
Role: role, Role: role,
Theme: "dark",
} }
user, err = h.DB.CreateUser(user) user, err = h.DB.CreateUser(user)

View File

@@ -24,6 +24,28 @@ func getStaticLogger() logging.Logger {
return logging.WithGroup("static") return logging.WithGroup("static")
} }
// getContentType returns the appropriate content type based on file extension
func getContentType(path string) string {
switch filepath.Ext(path) {
case ".js":
return "application/javascript"
case ".css":
return "text/css"
case ".html":
return "text/html"
case ".json":
return "application/json"
case ".svg":
return "image/svg+xml"
case ".xml":
return "application/xml"
case ".yaml", ".yml":
return "application/x-yaml"
default:
return "application/octet-stream"
}
}
// ServeHTTP serves the static files // ServeHTTP serves the static files
func (h *StaticHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { func (h *StaticHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
log := getStaticLogger().With( log := getStaticLogger().With(
@@ -77,23 +99,28 @@ func (h *StaticHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return return
} }
// Check for pre-compressed version // Check for pre-compressed versions (prefer brotli over gzip)
if strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") { acceptEncoding := r.Header.Get("Accept-Encoding")
// Try brotli first (better compression ratio)
if strings.Contains(acceptEncoding, "br") {
brPath := cleanPath + ".br"
if _, err := os.Stat(brPath); err == nil {
w.Header().Set("Content-Encoding", "br")
w.Header().Set("Content-Type", getContentType(cleanPath))
w.Header().Set("Vary", "Accept-Encoding")
http.ServeFile(w, r, brPath)
return
}
}
// Fall back to gzip
if strings.Contains(acceptEncoding, "gzip") {
gzPath := cleanPath + ".gz" gzPath := cleanPath + ".gz"
if _, err := os.Stat(gzPath); err == nil { if _, err := os.Stat(gzPath); err == nil {
w.Header().Set("Content-Encoding", "gzip") w.Header().Set("Content-Encoding", "gzip")
w.Header().Set("Content-Type", getContentType(cleanPath))
// Set proper content type based on original file w.Header().Set("Vary", "Accept-Encoding")
contentType := "application/octet-stream"
switch filepath.Ext(cleanPath) {
case ".js":
contentType = "application/javascript"
case ".css":
contentType = "text/css"
case ".html":
contentType = "text/html"
}
w.Header().Set("Content-Type", contentType)
http.ServeFile(w, r, gzPath) http.ServeFile(w, r, gzPath)
return return
} }

View File

@@ -26,8 +26,12 @@ func TestStaticHandler_Integration(t *testing.T) {
"index.html": []byte("<html><body>Index</body></html>"), "index.html": []byte("<html><body>Index</body></html>"),
"assets/style.css": []byte("body { color: blue; }"), "assets/style.css": []byte("body { color: blue; }"),
"assets/style.css.gz": []byte("gzipped css content"), "assets/style.css.gz": []byte("gzipped css content"),
"assets/style.css.br": []byte("brotli css content"),
"assets/script.js": []byte("console.log('test');"), "assets/script.js": []byte("console.log('test');"),
"assets/script.js.gz": []byte("gzipped js content"), "assets/script.js.gz": []byte("gzipped js content"),
"assets/script.js.br": []byte("brotli js content"),
"assets/app.js": []byte("console.log('app');"),
"assets/app.js.br": []byte("brotli app content"),
"subdir/page.html": []byte("<html><body>Page</body></html>"), "subdir/page.html": []byte("<html><body>Page</body></html>"),
"subdir/page.html.gz": []byte("gzipped html content"), "subdir/page.html.gz": []byte("gzipped html content"),
} }
@@ -52,6 +56,7 @@ func TestStaticHandler_Integration(t *testing.T) {
wantType string wantType string
wantEncoding string wantEncoding string
wantCacheHeader string wantCacheHeader string
wantVary string
}{ }{
{ {
name: "serve index.html", name: "serve index.html",
@@ -69,6 +74,7 @@ func TestStaticHandler_Integration(t *testing.T) {
wantType: "text/css", wantType: "text/css",
wantEncoding: "gzip", wantEncoding: "gzip",
wantCacheHeader: "public, max-age=31536000", wantCacheHeader: "public, max-age=31536000",
wantVary: "Accept-Encoding",
}, },
{ {
name: "serve JS with gzip support", name: "serve JS with gzip support",
@@ -79,6 +85,7 @@ func TestStaticHandler_Integration(t *testing.T) {
wantType: "application/javascript", wantType: "application/javascript",
wantEncoding: "gzip", wantEncoding: "gzip",
wantCacheHeader: "public, max-age=31536000", wantCacheHeader: "public, max-age=31536000",
wantVary: "Accept-Encoding",
}, },
{ {
name: "serve CSS without gzip", name: "serve CSS without gzip",
@@ -114,6 +121,50 @@ func TestStaticHandler_Integration(t *testing.T) {
wantBody: []byte("<html><body>Index</body></html>"), wantBody: []byte("<html><body>Index</body></html>"),
wantType: "text/html; charset=utf-8", wantType: "text/html; charset=utf-8",
}, },
{
name: "serve CSS with brotli support",
path: "/assets/style.css",
acceptEncoding: "br",
wantStatus: http.StatusOK,
wantBody: []byte("brotli css content"),
wantType: "text/css",
wantEncoding: "br",
wantCacheHeader: "public, max-age=31536000",
wantVary: "Accept-Encoding",
},
{
name: "serve JS with brotli support",
path: "/assets/script.js",
acceptEncoding: "br",
wantStatus: http.StatusOK,
wantBody: []byte("brotli js content"),
wantType: "application/javascript",
wantEncoding: "br",
wantCacheHeader: "public, max-age=31536000",
wantVary: "Accept-Encoding",
},
{
name: "prefer brotli over gzip when both supported",
path: "/assets/script.js",
acceptEncoding: "gzip, br",
wantStatus: http.StatusOK,
wantBody: []byte("brotli js content"),
wantType: "application/javascript",
wantEncoding: "br",
wantCacheHeader: "public, max-age=31536000",
wantVary: "Accept-Encoding",
},
{
name: "fallback to gzip when brotli not available",
path: "/assets/app.js",
acceptEncoding: "gzip, br",
wantStatus: http.StatusOK,
wantBody: []byte("brotli app content"),
wantType: "application/javascript",
wantEncoding: "br",
wantCacheHeader: "public, max-age=31536000",
wantVary: "Accept-Encoding",
},
} }
for _, tc := range tests { for _, tc := range tests {
@@ -139,6 +190,10 @@ func TestStaticHandler_Integration(t *testing.T) {
if tc.wantCacheHeader != "" { if tc.wantCacheHeader != "" {
assert.Equal(t, tc.wantCacheHeader, w.Header().Get("Cache-Control")) assert.Equal(t, tc.wantCacheHeader, w.Header().Get("Cache-Control"))
} }
if tc.wantVary != "" {
assert.Equal(t, tc.wantVary, w.Header().Get("Vary"))
}
} }
}) })
} }

View File

@@ -16,6 +16,7 @@ type UpdateProfileRequest struct {
Email string `json:"email"` Email string `json:"email"`
CurrentPassword string `json:"currentPassword"` CurrentPassword string `json:"currentPassword"`
NewPassword string `json:"newPassword"` NewPassword string `json:"newPassword"`
Theme string `json:"theme"`
} }
// DeleteAccountRequest represents a user account deletion request // DeleteAccountRequest represents a user account deletion request
@@ -149,6 +150,19 @@ func (h *Handler) UpdateProfile() http.HandlerFunc {
updates["displayNameChanged"] = true updates["displayNameChanged"] = true
} }
// Update theme if provided
if req.Theme != "" {
// Validate theme value, fallback to "dark" if invalid
if req.Theme != "light" && req.Theme != "dark" {
log.Debug("invalid theme value, falling back to dark",
"theme", req.Theme,
)
req.Theme = "dark"
}
user.Theme = req.Theme
updates["themeChanged"] = true
}
// Update user in database // Update user in database
if err := h.DB.UpdateUser(user); err != nil { if err := h.DB.UpdateUser(user); err != nil {
log.Error("failed to update user in database", log.Error("failed to update user in database",

View File

@@ -87,7 +87,7 @@ func (h *Handler) CreateWorkspace() http.HandlerFunc {
"clientIP", r.RemoteAddr, "clientIP", r.RemoteAddr,
) )
var workspace models.Workspace var workspace models.Workspace
if err := json.NewDecoder(r.Body).Decode(&workspace); err != nil { if err := json.NewDecoder(r.Body).Decode(&workspace); err != nil {
log.Debug("invalid request body received", log.Debug("invalid request body received",
"error", err.Error(), "error", err.Error(),
@@ -104,7 +104,21 @@ func (h *Handler) CreateWorkspace() http.HandlerFunc {
return return
} }
// Get user to access their theme preference
user, err := h.DB.GetUserByID(ctx.UserID)
if err != nil {
log.Error("failed to fetch user from database",
"error", err.Error(),
)
respondError(w, "Failed to get user", http.StatusInternalServerError)
return
}
workspace.UserID = ctx.UserID workspace.UserID = ctx.UserID
// Use user's theme as default if not provided
if workspace.Theme == "" {
workspace.Theme = user.Theme
}
if err := h.DB.CreateWorkspace(&workspace); err != nil { if err := h.DB.CreateWorkspace(&workspace); err != nil {
log.Error("failed to create workspace in database", log.Error("failed to create workspace in database",
"error", err.Error(), "error", err.Error(),
@@ -145,6 +159,7 @@ func (h *Handler) CreateWorkspace() http.HandlerFunc {
log.Info("workspace created", log.Info("workspace created",
"workspaceID", workspace.ID, "workspaceID", workspace.ID,
"workspaceName", workspace.Name, "workspaceName", workspace.Name,
"theme", workspace.Theme,
"gitEnabled", workspace.GitEnabled, "gitEnabled", workspace.GitEnabled,
) )
respondJSON(w, workspace) respondJSON(w, workspace)

View File

@@ -25,6 +25,7 @@ type User struct {
DisplayName string `json:"displayName" db:"display_name"` DisplayName string `json:"displayName" db:"display_name"`
PasswordHash string `json:"-" db:"password_hash"` PasswordHash string `json:"-" db:"password_hash"`
Role UserRole `json:"role" db:"role" validate:"required,oneof=admin editor viewer"` Role UserRole `json:"role" db:"role" validate:"required,oneof=admin editor viewer"`
Theme string `json:"theme" db:"theme" validate:"required,oneof=light dark"`
CreatedAt time.Time `json:"createdAt" db:"created_at,default"` CreatedAt time.Time `json:"createdAt" db:"created_at,default"`
LastWorkspaceID int `json:"lastWorkspaceId" db:"last_workspace_id"` LastWorkspaceID int `json:"lastWorkspaceId" db:"last_workspace_id"`
} }

View File

@@ -13,7 +13,7 @@ type Workspace struct {
LastOpenedFilePath string `json:"lastOpenedFilePath" db:"last_opened_file_path"` LastOpenedFilePath string `json:"lastOpenedFilePath" db:"last_opened_file_path"`
// Integrated settings // Integrated settings
Theme string `json:"theme" db:"theme" validate:"oneof=light dark"` Theme string `json:"theme" db:"theme" validate:"required,oneof=light dark"`
AutoSave bool `json:"autoSave" db:"auto_save"` AutoSave bool `json:"autoSave" db:"auto_save"`
ShowHiddenFiles bool `json:"showHiddenFiles" db:"show_hidden_files"` ShowHiddenFiles bool `json:"showHiddenFiles" db:"show_hidden_files"`
GitEnabled bool `json:"gitEnabled" db:"git_enabled"` GitEnabled bool `json:"gitEnabled" db:"git_enabled"`
@@ -40,7 +40,7 @@ func (w *Workspace) ValidateGitSettings() error {
func (w *Workspace) SetDefaultSettings() { func (w *Workspace) SetDefaultSettings() {
if w.Theme == "" { if w.Theme == "" {
w.Theme = "light" w.Theme = "dark"
} }
w.AutoSave = w.AutoSave || false w.AutoSave = w.AutoSave || false