80 Commits

Author SHA1 Message Date
d9f1a16d94 Merge pull request #96 from lordmathis/chore/update-deps
Bump React and TypeScript types
2025-11-15 15:21:49 +01:00
0999fa9315 Fix FileTree tests with async rendering and mock resize observer 2025-11-15 15:16:19 +01:00
cc8c8fd414 Fix typescript issues 2025-11-15 14:56:48 +01:00
9ba37b3342 Update react dependencies 2025-11-15 14:47:13 +01:00
140ccd6879 Bump React and TypeScript types 2025-11-15 01:19:01 +01:00
7424ce4385 Merge pull request #91 from lordmathis/dependabot/npm_and_yarn/app/minor-and-patch-5c01766afc
Bump sass from 1.93.3 to 1.94.0 in /app in the minor-and-patch group
2025-11-12 22:09:24 +01:00
c998800990 Merge branch 'main' into dependabot/npm_and_yarn/app/minor-and-patch-5c01766afc 2025-11-12 22:04:46 +01:00
c86d627053 Merge pull request #94 from lordmathis/dependabot/npm_and_yarn/app/types/node-24.10.1
Bump @types/node from 22.14.0 to 24.10.1 in /app
2025-11-12 22:04:31 +01:00
dependabot[bot]
5837cf7316 Bump @types/node from 22.14.0 to 24.10.1 in /app
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 22.14.0 to 24.10.1.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 24.10.1
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-12 20:57:11 +00:00
92a3dcdab7 Merge pull request #95 from lordmathis/dependabot/npm_and_yarn/app/vitejs/plugin-react-5.1.1
Bump @vitejs/plugin-react from 4.3.4 to 5.1.1 in /app
2025-11-12 21:55:57 +01:00
857b56fec2 Merge branch 'main' into dependabot/npm_and_yarn/app/vitejs/plugin-react-5.1.1 2025-11-12 21:51:41 +01:00
7e48ec935c Merge pull request #93 from lordmathis/dependabot/npm_and_yarn/app/eslint-plugin-react-hooks-7.0.1
Bump eslint-plugin-react-hooks from 5.2.0 to 7.0.1 in /app
2025-11-12 21:51:27 +01:00
b728c240b6 Merge branch 'main' into dependabot/npm_and_yarn/app/minor-and-patch-5c01766afc 2025-11-12 21:51:06 +01:00
e9aa611a41 Merge branch 'main' into dependabot/npm_and_yarn/app/eslint-plugin-react-hooks-7.0.1 2025-11-12 21:46:26 +01:00
441c31eb14 Merge branch 'main' into dependabot/npm_and_yarn/app/vitejs/plugin-react-5.1.1 2025-11-12 21:44:27 +01:00
fd75d6f8a0 Merge pull request #90 from lordmathis/dependabot/go_modules/server/minor-and-patch-2860a76c8c
Bump the minor-and-patch group in /server with 11 updates
2025-11-12 21:43:44 +01:00
dependabot[bot]
f54852bbad Bump @vitejs/plugin-react from 4.3.4 to 5.1.1 in /app
Bumps [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react) from 4.3.4 to 5.1.1.
- [Release notes](https://github.com/vitejs/vite-plugin-react/releases)
- [Changelog](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite-plugin-react/commits/plugin-react@5.1.1/packages/plugin-react)

---
updated-dependencies:
- dependency-name: "@vitejs/plugin-react"
  dependency-version: 5.1.1
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-12 20:37:13 +00:00
dependabot[bot]
9101acd171 Bump eslint-plugin-react-hooks from 5.2.0 to 7.0.1 in /app
Bumps [eslint-plugin-react-hooks](https://github.com/facebook/react/tree/HEAD/packages/eslint-plugin-react-hooks) from 5.2.0 to 7.0.1.
- [Release notes](https://github.com/facebook/react/releases)
- [Changelog](https://github.com/facebook/react/blob/main/packages/eslint-plugin-react-hooks/CHANGELOG.md)
- [Commits](https://github.com/facebook/react/commits/HEAD/packages/eslint-plugin-react-hooks)

---
updated-dependencies:
- dependency-name: eslint-plugin-react-hooks
  dependency-version: 7.0.1
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-12 20:36:49 +00:00
dependabot[bot]
d9a26830ff Bump sass from 1.93.3 to 1.94.0 in /app in the minor-and-patch group
Bumps the minor-and-patch group in /app with 1 update: [sass](https://github.com/sass/dart-sass).


Updates `sass` from 1.93.3 to 1.94.0
- [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.93.3...1.94.0)

---
updated-dependencies:
- dependency-name: sass
  dependency-version: 1.94.0
  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-12 20:36:23 +00:00
dependabot[bot]
c095c4a049 Bump the minor-and-patch group in /server with 11 updates
Bumps the minor-and-patch group in /server with 11 updates:

| Package | From | To |
| --- | --- | --- |
| [github.com/go-chi/chi/v5](https://github.com/go-chi/chi) | `5.2.2` | `5.2.3` |
| [github.com/go-chi/cors](https://github.com/go-chi/cors) | `1.2.1` | `1.2.2` |
| [github.com/go-chi/httprate](https://github.com/go-chi/httprate) | `0.14.1` | `0.15.0` |
| [github.com/go-git/go-git/v5](https://github.com/go-git/go-git) | `5.13.1` | `5.16.3` |
| [github.com/go-playground/validator/v10](https://github.com/go-playground/validator) | `10.22.1` | `10.28.0` |
| [github.com/golang-jwt/jwt/v5](https://github.com/golang-jwt/jwt) | `5.2.2` | `5.3.0` |
| [github.com/golang-migrate/migrate/v4](https://github.com/golang-migrate/migrate) | `4.18.2` | `4.19.0` |
| [github.com/mattn/go-sqlite3](https://github.com/mattn/go-sqlite3) | `1.14.23` | `1.14.32` |
| [github.com/stretchr/testify](https://github.com/stretchr/testify) | `1.10.0` | `1.11.1` |
| [github.com/swaggo/swag](https://github.com/swaggo/swag) | `1.16.4` | `1.16.6` |
| [golang.org/x/crypto](https://github.com/golang/crypto) | `0.36.0` | `0.42.0` |


Updates `github.com/go-chi/chi/v5` from 5.2.2 to 5.2.3
- [Release notes](https://github.com/go-chi/chi/releases)
- [Changelog](https://github.com/go-chi/chi/blob/master/CHANGELOG.md)
- [Commits](https://github.com/go-chi/chi/compare/v5.2.2...v5.2.3)

Updates `github.com/go-chi/cors` from 1.2.1 to 1.2.2
- [Release notes](https://github.com/go-chi/cors/releases)
- [Commits](https://github.com/go-chi/cors/compare/v1.2.1...v1.2.2)

Updates `github.com/go-chi/httprate` from 0.14.1 to 0.15.0
- [Release notes](https://github.com/go-chi/httprate/releases)
- [Commits](https://github.com/go-chi/httprate/compare/v0.14.1...v0.15.0)

Updates `github.com/go-git/go-git/v5` from 5.13.1 to 5.16.3
- [Release notes](https://github.com/go-git/go-git/releases)
- [Commits](https://github.com/go-git/go-git/compare/v5.13.1...v5.16.3)

Updates `github.com/go-playground/validator/v10` from 10.22.1 to 10.28.0
- [Release notes](https://github.com/go-playground/validator/releases)
- [Commits](https://github.com/go-playground/validator/compare/v10.22.1...v10.28.0)

Updates `github.com/golang-jwt/jwt/v5` from 5.2.2 to 5.3.0
- [Release notes](https://github.com/golang-jwt/jwt/releases)
- [Changelog](https://github.com/golang-jwt/jwt/blob/main/VERSION_HISTORY.md)
- [Commits](https://github.com/golang-jwt/jwt/compare/v5.2.2...v5.3.0)

Updates `github.com/golang-migrate/migrate/v4` from 4.18.2 to 4.19.0
- [Release notes](https://github.com/golang-migrate/migrate/releases)
- [Changelog](https://github.com/golang-migrate/migrate/blob/master/.goreleaser.yml)
- [Commits](https://github.com/golang-migrate/migrate/compare/v4.18.2...v4.19.0)

Updates `github.com/mattn/go-sqlite3` from 1.14.23 to 1.14.32
- [Release notes](https://github.com/mattn/go-sqlite3/releases)
- [Commits](https://github.com/mattn/go-sqlite3/compare/v1.14.23...v1.14.32)

Updates `github.com/stretchr/testify` from 1.10.0 to 1.11.1
- [Release notes](https://github.com/stretchr/testify/releases)
- [Commits](https://github.com/stretchr/testify/compare/v1.10.0...v1.11.1)

Updates `github.com/swaggo/swag` from 1.16.4 to 1.16.6
- [Release notes](https://github.com/swaggo/swag/releases)
- [Changelog](https://github.com/swaggo/swag/blob/master/.goreleaser.yml)
- [Commits](https://github.com/swaggo/swag/compare/v1.16.4...v1.16.6)

Updates `golang.org/x/crypto` from 0.36.0 to 0.42.0
- [Commits](https://github.com/golang/crypto/compare/v0.36.0...v0.42.0)

---
updated-dependencies:
- dependency-name: github.com/go-chi/chi/v5
  dependency-version: 5.2.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: github.com/go-chi/cors
  dependency-version: 1.2.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: github.com/go-chi/httprate
  dependency-version: 0.15.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: github.com/go-git/go-git/v5
  dependency-version: 5.16.3
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: github.com/go-playground/validator/v10
  dependency-version: 10.28.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: github.com/golang-jwt/jwt/v5
  dependency-version: 5.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: github.com/golang-migrate/migrate/v4
  dependency-version: 4.19.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: github.com/mattn/go-sqlite3
  dependency-version: 1.14.32
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: github.com/stretchr/testify
  dependency-version: 1.11.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: github.com/swaggo/swag
  dependency-version: 1.16.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: golang.org/x/crypto
  dependency-version: 0.42.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-12 20:36:07 +00:00
728367a6f6 Merge pull request #89 from lordmathis/lordmathis-patch-1
Update dependabot.yml
2025-11-12 21:34:42 +01:00
dd854b755e Update dependabot.yml 2025-11-12 21:31:27 +01:00
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
c11d956ced Merge pull request #66 from lordmathis/fix/head-request
Handle HEAD requests with static router
2025-10-23 20:35:47 +02:00
9a232819a8 Handle HEAD requests with static router 2025-10-23 20:26:34 +02:00
f9ce8b9e9f Merge pull request #65 from lordmathis/fix/code-highlight
Fix code highlight theme change
2025-10-23 19:24:14 +02:00
a6d2663a7d Add type declarations for CSS imports with ?inline modifier 2025-10-23 19:19:27 +02:00
071e99f4da Remove documentation.md 2025-10-23 19:17:51 +02:00
b13ee987c7 Fix code highlight theme change 2025-10-23 19:12:09 +02:00
543dbe6ffe Merge pull request #64 from lordmathis/fix/file-rename
Fix move file on frontend
2025-10-22 21:55:52 +02:00
d0842c515f Change move file endpoint from PUT to POST and add integration tests for file moving and renaming 2025-10-22 21:50:37 +02:00
01d9a984fc Merge pull request #63 from lordmathis/dependabot/npm_and_yarn/app/npm_and_yarn-7372c7abf2
Bump vite from 6.3.6 to 6.4.1 in /app in the npm_and_yarn group across 1 directory
2025-10-21 10:21:46 +02:00
dependabot[bot]
a3975c9acd Bump vite in /app in the npm_and_yarn group across 1 directory
Bumps the npm_and_yarn group with 1 update in the /app directory: [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite).


Updates `vite` from 6.3.6 to 6.4.1
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/create-vite@6.4.1/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 6.4.1
  dependency-type: direct:development
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-21 05:07:05 +00:00
e82f25a2ed Merge pull request #62 from lordmathis/fix/image-preview
Fix get image url
2025-10-11 22:56:49 +02:00
ec89f95880 Fix get image url 2025-10-11 22:51:08 +02:00
f101376bef Merge pull request #61 from lordmathis/fix/syntax-highlight
Replace react-syntax-highlighter with rehype-highlight
2025-10-11 22:49:21 +02:00
d582b1a1e9 Add syntax highlighting themes for markdown preview 2025-10-11 22:26:21 +02:00
aca127e52e Move rate limiting for authentication endpoints to the public routes group 2025-10-11 22:16:15 +02:00
9ca8a46093 Replace react-syntax-highlighter with rehype-highlight 2025-10-11 22:02:02 +02:00
72 changed files with 2886 additions and 3724 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: "/server"
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

3122
app/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -29,57 +29,57 @@
}, },
"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", "@floating-ui/react": "^0.27.16",
"@mantine/modals": "^7.13.2", "@mantine/core": "^8.3.7",
"@mantine/notifications": "^7.13.2", "@mantine/hooks": "^8.3.7",
"@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": "^19.2.0",
"react-arborist": "^3.4.0", "react-arborist": "^3.4.3",
"react-dom": "^18.3.1", "react-dom": "^19.2.0",
"react-syntax-highlighter": "^15.6.6", "rehype-highlight": "^7.0.2",
"rehype-mathjax": "^6.0.0", "rehype-mathjax": "^7.1.0",
"rehype-prism": "^2.3.3",
"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": "^24.10.1",
"@types/react": "^18.3.20", "@types/react": "^19.2.5",
"@types/react-dom": "^18.3.6", "@types/react-dom": "^19.2.3",
"@types/react-syntax-highlighter": "^15.5.13", "@typescript-eslint/eslint-plugin": "^8.46.4",
"@typescript-eslint/eslint-plugin": "^8.32.1",
"@typescript-eslint/parser": "^8.32.1", "@typescript-eslint/parser": "^8.32.1",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^5.1.1",
"@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": "^7.0.1",
"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.94.0",
"typescript": "^5.8.2", "typescript": "^5.9.3",
"vite": "^6.3.6", "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

@@ -120,6 +120,34 @@ describe('MarkdownPreview', () => {
}); });
}); });
it('renders code blocks with correct structure for theme switching', async () => {
const content = '```javascript\nconst hello = "world";\n```';
render(
<MarkdownPreview
content={content}
handleFileSelect={mockHandleFileSelect}
/>
);
await waitFor(() => {
// Check that rehype-highlight generates the correct structure
const preElement = screen
.getByRole('code', { hidden: true })
.closest('pre');
const codeElement = preElement?.querySelector('code');
expect(preElement).toBeInTheDocument();
expect(codeElement).toBeInTheDocument();
// The code element should have hljs class for theme switching to work
expect(codeElement).toHaveClass('hljs');
// Should also have language class
expect(codeElement).toHaveClass('language-javascript');
});
});
it('handles image loading errors gracefully', async () => { it('handles image loading errors gracefully', async () => {
const content = '![Test Image](invalid-image.jpg)'; const content = '![Test Image](invalid-image.jpg)';

View File

@@ -1,15 +1,16 @@
import React, { useState, useEffect, useMemo, type ReactNode } from 'react'; import React, { useState, useEffect, useMemo, type ReactNode } from 'react';
import { unified, type Preset } from 'unified'; import { unified } from 'unified';
import remarkParse from 'remark-parse'; import remarkParse from 'remark-parse';
import remarkMath from 'remark-math'; import remarkMath from 'remark-math';
import remarkRehype from 'remark-rehype'; import remarkRehype from 'remark-rehype';
import rehypeMathjax from 'rehype-mathjax'; import rehypeMathjax from 'rehype-mathjax';
import rehypeReact, { type Options } from 'rehype-react'; import rehypeReact, { type Options } from 'rehype-react';
import rehypePrism from 'rehype-prism'; import rehypeHighlight from 'rehype-highlight';
import * as prod from 'react/jsx-runtime'; import * as prod from 'react/jsx-runtime';
import { notifications } from '@mantine/notifications'; import { notifications } from '@mantine/notifications';
import { remarkWikiLinks } from '../../utils/remarkWikiLinks'; import { remarkWikiLinks } from '../../utils/remarkWikiLinks';
import { useWorkspace } from '../../hooks/useWorkspace'; import { useWorkspace } from '../../hooks/useWorkspace';
import { useHighlightTheme } from '../../hooks/useHighlightTheme';
interface MarkdownPreviewProps { interface MarkdownPreviewProps {
content: string; content: string;
@@ -28,12 +29,6 @@ interface MarkdownLinkProps {
[key: string]: unknown; [key: string]: unknown;
} }
interface MarkdownCodeProps {
children: ReactNode;
className?: string;
[key: string]: unknown;
}
const MarkdownPreview: React.FC<MarkdownPreviewProps> = ({ const MarkdownPreview: React.FC<MarkdownPreviewProps> = ({
content, content,
handleFileSelect, handleFileSelect,
@@ -42,7 +37,10 @@ const MarkdownPreview: React.FC<MarkdownPreviewProps> = ({
null null
); );
const baseUrl = window.API_BASE_URL; const baseUrl = window.API_BASE_URL;
const { currentWorkspace } = useWorkspace(); const { currentWorkspace, colorScheme } = useWorkspace();
// Use the highlight theme hook
useHighlightTheme(colorScheme === 'auto' ? 'light' : colorScheme);
const processor = useMemo(() => { const processor = useMemo(() => {
const handleLinkClick = ( const handleLinkClick = (
@@ -82,7 +80,7 @@ const MarkdownPreview: React.FC<MarkdownPreviewProps> = ({
.use(remarkMath) .use(remarkMath)
.use(remarkRehype) .use(remarkRehype)
.use(rehypeMathjax) .use(rehypeMathjax)
.use(rehypePrism as Preset) .use(rehypeHighlight)
.use(rehypeReact, { .use(rehypeReact, {
jsx: prod.jsx, jsx: prod.jsx,
jsxs: prod.jsxs, jsxs: prod.jsxs,
@@ -107,13 +105,6 @@ const MarkdownPreview: React.FC<MarkdownPreviewProps> = ({
{children} {children}
</a> </a>
), ),
code: ({ children, className, ...props }: MarkdownCodeProps) => {
return (
<pre className={className}>
<code {...props}>{children}</code>
</pre>
);
},
}, },
} as Options); } as Options);
}, [currentWorkspace?.name, baseUrl, handleFileSelect]); }, [currentWorkspace?.name, baseUrl, handleFileSelect]);

View File

@@ -69,7 +69,19 @@ vi.mock('react-arborist', () => ({
// Mock resize observer hook // Mock resize observer hook
vi.mock('@react-hook/resize-observer', () => ({ vi.mock('@react-hook/resize-observer', () => ({
default: vi.fn(), default: vi.fn(
(
_target: unknown,
callback: (entry: { contentRect: { width: number; height: number } }) => void
) => {
// Immediately call the callback with a mock entry to provide size
if (callback) {
setTimeout(() => {
callback({ contentRect: { width: 300, height: 600 } });
}, 0);
}
}
),
})); }));
// Mock contexts // Mock contexts
@@ -172,7 +184,7 @@ describe('FileTree', () => {
vi.clearAllMocks(); vi.clearAllMocks();
}); });
it('renders file tree with files', () => { it('renders file tree with files', async () => {
const { getByTestId } = render( const { getByTestId } = render(
<TestWrapper> <TestWrapper>
<FileTree <FileTree
@@ -184,7 +196,9 @@ describe('FileTree', () => {
</TestWrapper> </TestWrapper>
); );
await waitFor(() => {
expect(getByTestId('file-tree')).toBeInTheDocument(); expect(getByTestId('file-tree')).toBeInTheDocument();
});
expect(getByTestId('file-node-1')).toBeInTheDocument(); expect(getByTestId('file-node-1')).toBeInTheDocument();
expect(getByTestId('file-node-2')).toBeInTheDocument(); expect(getByTestId('file-node-2')).toBeInTheDocument();
}); });
@@ -201,6 +215,10 @@ describe('FileTree', () => {
</TestWrapper> </TestWrapper>
); );
await waitFor(() => {
expect(getByTestId('file-node-1')).toBeInTheDocument();
});
const fileNode = getByTestId('file-node-1'); const fileNode = getByTestId('file-node-1');
fireEvent.click(fileNode); fireEvent.click(fileNode);
@@ -209,7 +227,7 @@ describe('FileTree', () => {
}); });
}); });
it('filters out hidden files when showHiddenFiles is false', () => { it('filters out hidden files when showHiddenFiles is false', async () => {
const { getByTestId, queryByTestId } = render( const { getByTestId, queryByTestId } = render(
<TestWrapper> <TestWrapper>
<FileTree <FileTree
@@ -221,6 +239,10 @@ describe('FileTree', () => {
</TestWrapper> </TestWrapper>
); );
await waitFor(() => {
expect(getByTestId('file-node-1')).toBeInTheDocument();
});
// Should show regular files // Should show regular files
expect(getByTestId('file-node-1')).toBeInTheDocument(); expect(getByTestId('file-node-1')).toBeInTheDocument();
expect(getByTestId('file-node-2')).toBeInTheDocument(); expect(getByTestId('file-node-2')).toBeInTheDocument();
@@ -229,7 +251,7 @@ describe('FileTree', () => {
expect(queryByTestId('file-node-4')).not.toBeInTheDocument(); expect(queryByTestId('file-node-4')).not.toBeInTheDocument();
}); });
it('shows hidden files when showHiddenFiles is true', () => { it('shows hidden files when showHiddenFiles is true', async () => {
const { getByTestId } = render( const { getByTestId } = render(
<TestWrapper> <TestWrapper>
<FileTree <FileTree
@@ -241,13 +263,17 @@ describe('FileTree', () => {
</TestWrapper> </TestWrapper>
); );
await waitFor(() => {
expect(getByTestId('file-node-1')).toBeInTheDocument();
});
// Should show all files including hidden // Should show all files including hidden
expect(getByTestId('file-node-1')).toBeInTheDocument(); expect(getByTestId('file-node-1')).toBeInTheDocument();
expect(getByTestId('file-node-2')).toBeInTheDocument(); expect(getByTestId('file-node-2')).toBeInTheDocument();
expect(getByTestId('file-node-4')).toBeInTheDocument(); expect(getByTestId('file-node-4')).toBeInTheDocument();
}); });
it('renders empty tree when no files provided', () => { it('renders empty tree when no files provided', async () => {
const { getByTestId } = render( const { getByTestId } = render(
<TestWrapper> <TestWrapper>
<FileTree <FileTree
@@ -259,6 +285,10 @@ describe('FileTree', () => {
</TestWrapper> </TestWrapper>
); );
await waitFor(() => {
expect(getByTestId('file-tree')).toBeInTheDocument();
});
const tree = getByTestId('file-tree'); const tree = getByTestId('file-tree');
expect(tree).toBeInTheDocument(); expect(tree).toBeInTheDocument();
expect(tree.children).toHaveLength(0); expect(tree.children).toHaveLength(0);
@@ -276,6 +306,10 @@ describe('FileTree', () => {
</TestWrapper> </TestWrapper>
); );
await waitFor(() => {
expect(getByTestId('file-node-2')).toBeInTheDocument();
});
// Click on folder (has children) // Click on folder (has children)
const folderNode = getByTestId('file-node-2'); const folderNode = getByTestId('file-node-2');
fireEvent.click(folderNode); fireEvent.click(folderNode);

View File

@@ -1,4 +1,4 @@
import React, { useRef, useState, useLayoutEffect, useCallback } from 'react'; import React, { useRef, useState, useCallback } from 'react';
import { Tree, type NodeApi } from 'react-arborist'; import { Tree, type NodeApi } from 'react-arborist';
import { import {
IconFile, IconFile,
@@ -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';
@@ -23,15 +23,11 @@ interface FileTreeProps {
loadFileList: () => Promise<void>; loadFileList: () => Promise<void>;
} }
const useSize = (target: React.RefObject<HTMLElement>): Size | undefined => { const useSize = (
target: React.RefObject<HTMLElement | null>
): Size | undefined => {
const [size, setSize] = useState<Size>(); const [size, setSize] = useState<Size>();
useLayoutEffect(() => {
if (target.current) {
setSize(target.current.getBoundingClientRect());
}
}, [target]);
useResizeObserver(target, (entry) => setSize(entry.contentRect)); useResizeObserver(target, (entry) => setSize(entry.contentRect));
return size; return size;
}; };
@@ -53,13 +49,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,7 +64,6 @@ 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={{
@@ -82,9 +76,14 @@ function Node({
overflow: 'hidden', overflow: 'hidden',
// Add visual feedback when being dragged // Add visual feedback when being dragged
opacity: node.state?.isDragging ? 0.5 : 1, 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} onClick={handleClick}
{...rest} title={node.data.name}
> >
<FileIcon node={node} /> <FileIcon node={node} />
<span <span
@@ -99,7 +98,6 @@ function Node({
{node.data.name} {node.data.name}
</span> </span>
</div> </div>
</Tooltip>
); );
} }
@@ -205,16 +203,17 @@ 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) => {
// Only handle if it's an external file drag
if (e.dataTransfer.types.includes('Files')) {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
@@ -222,24 +221,28 @@ export const FileTree: React.FC<FileTreeProps> = ({
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) => {
// Only handle external file drags
if (e.dataTransfer.types.includes('Files')) {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
// Set the drop effect to indicate this is a valid drop target // Set the drop effect to indicate this is a valid drop target
e.dataTransfer.dropEffect = 'copy'; e.dataTransfer.dropEffect = 'copy';
}
}, []); }, []);
const handleDrop = useCallback( const handleDrop = useCallback(
(e: React.DragEvent) => { (e: React.DragEvent) => {
const { files } = e.dataTransfer;
// Only handle if it's an external file drop
if (files && files.length > 0) {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
setIsDragOver(false); setIsDragOver(false);
const { files } = e.dataTransfer;
if (files && files.length > 0) {
const uploadFiles = async () => { const uploadFiles = async () => {
try { try {
const success = await handleUpload(files); const success = await handleUpload(files);
@@ -305,7 +308,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,269 @@
import React, { useRef, 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 | null>
): Size | undefined => {
const [size, setSize] = useState<Size>();
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,16 +1,30 @@
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,
}) => {
const { user } = useAuth();
const currentTheme = settings.theme || user?.theme || Theme.Dark;
const handleThemeToggle = () => {
const newTheme = currentTheme === Theme.Dark ? Theme.Light : Theme.Dark;
if (onThemeChange) {
onThemeChange(newTheme);
}
};
return (
<Box> <Box>
<Stack gap="md"> <Stack gap="md">
<TextInput <TextInput
@@ -29,8 +43,24 @@ const ProfileSettings: React.FC<ProfileSettingsProps> = ({
placeholder="Enter email" placeholder="Enter email"
data-testid="email-input" 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> </Stack>
</Box> </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

@@ -0,0 +1,36 @@
import { useEffect } from 'react';
// Import theme CSS as text that will be bundled
import atomOneLightTheme from 'highlight.js/styles/atom-one-light.css?inline';
import atomOneDarkTheme from 'highlight.js/styles/atom-one-dark.css?inline';
export const useHighlightTheme = (colorScheme: 'light' | 'dark') => {
useEffect(() => {
// Remove existing highlight theme
const existingStylesheet = document.querySelector(
'style[data-highlight-theme]'
);
if (existingStylesheet) {
existingStylesheet.remove();
}
// Add new theme stylesheet using bundled CSS
const style = document.createElement('style');
style.setAttribute('data-highlight-theme', 'true');
if (colorScheme === 'dark') {
style.textContent = atomOneDarkTheme as string;
} else {
style.textContent = atomOneLightTheme as string;
}
document.head.appendChild(style);
return () => {
// Cleanup on unmount
const stylesheet = document.querySelector('style[data-highlight-theme]');
if (stylesheet) {
stylesheet.remove();
}
};
}, [colorScheme]);
};

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

5
app/src/types/css-inline.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
// Type declarations for CSS imports with ?inline modifier
declare module '*.css?inline' {
const content: string;
export default content;
}

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

@@ -83,7 +83,7 @@ describe('fileHelpers', () => {
const filePath = 'folder/file.md'; const filePath = 'folder/file.md';
const expectedUrl = const expectedUrl =
'http://localhost:8080/api/v1/workspaces/my-workspace/files/folder%2Ffile.md'; 'http://localhost:8080/api/v1/workspaces/my-workspace/files/content?file_path=folder%2Ffile.md';
const actualUrl = getFileUrl(workspaceName, filePath); const actualUrl = getFileUrl(workspaceName, filePath);
expect(actualUrl).toBe(expectedUrl); expect(actualUrl).toBe(expectedUrl);
@@ -94,7 +94,7 @@ describe('fileHelpers', () => {
const filePath = 'file.md'; const filePath = 'file.md';
const expectedUrl = const expectedUrl =
'http://localhost:8080/api/v1/workspaces/my%20workspace%20with%20spaces/files/file.md'; 'http://localhost:8080/api/v1/workspaces/my%20workspace%20with%20spaces/files/content?file_path=file.md';
const actualUrl = getFileUrl(workspaceName, filePath); const actualUrl = getFileUrl(workspaceName, filePath);
expect(actualUrl).toBe(expectedUrl); expect(actualUrl).toBe(expectedUrl);
@@ -105,7 +105,7 @@ describe('fileHelpers', () => {
const filePath = 'folder with spaces/file with spaces.md'; const filePath = 'folder with spaces/file with spaces.md';
const expectedUrl = const expectedUrl =
'http://localhost:8080/api/v1/workspaces/workspace/files/folder%20with%20spaces%2Ffile%20with%20spaces.md'; 'http://localhost:8080/api/v1/workspaces/workspace/files/content?file_path=folder%20with%20spaces%2Ffile%20with%20spaces.md';
const actualUrl = getFileUrl(workspaceName, filePath); const actualUrl = getFileUrl(workspaceName, filePath);
expect(actualUrl).toBe(expectedUrl); expect(actualUrl).toBe(expectedUrl);
@@ -116,7 +116,7 @@ describe('fileHelpers', () => {
const filePath = 'file?name=test.md'; const filePath = 'file?name=test.md';
const expectedUrl = const expectedUrl =
'http://localhost:8080/api/v1/workspaces/test%26workspace/files/file%3Fname%3Dtest.md'; 'http://localhost:8080/api/v1/workspaces/test%26workspace/files/content?file_path=file%3Fname%3Dtest.md';
const actualUrl = getFileUrl(workspaceName, filePath); const actualUrl = getFileUrl(workspaceName, filePath);
expect(actualUrl).toBe(expectedUrl); expect(actualUrl).toBe(expectedUrl);
@@ -127,7 +127,7 @@ describe('fileHelpers', () => {
const filePath = 'ファイル.md'; const filePath = 'ファイル.md';
const expectedUrl = const expectedUrl =
'http://localhost:8080/api/v1/workspaces/%E3%83%97%E3%83%AD%E3%82%B8%E3%82%A7%E3%82%AF%E3%83%88/files/%E3%83%95%E3%82%A1%E3%82%A4%E3%83%AB.md'; 'http://localhost:8080/api/v1/workspaces/%E3%83%97%E3%83%AD%E3%82%B8%E3%82%A7%E3%82%AF%E3%83%88/files/content?file_path=%E3%83%95%E3%82%A1%E3%82%A4%E3%83%AB.md';
const actualUrl = getFileUrl(workspaceName, filePath); const actualUrl = getFileUrl(workspaceName, filePath);
expect(actualUrl).toBe(expectedUrl); expect(actualUrl).toBe(expectedUrl);
@@ -138,7 +138,7 @@ describe('fileHelpers', () => {
const filePath = 'projects/2024/q1/report.md'; const filePath = 'projects/2024/q1/report.md';
const expectedUrl = const expectedUrl =
'http://localhost:8080/api/v1/workspaces/docs/files/projects%2F2024%2Fq1%2Freport.md'; 'http://localhost:8080/api/v1/workspaces/docs/files/content?file_path=projects%2F2024%2Fq1%2Freport.md';
const actualUrl = getFileUrl(workspaceName, filePath); const actualUrl = getFileUrl(workspaceName, filePath);
expect(actualUrl).toBe(expectedUrl); expect(actualUrl).toBe(expectedUrl);
@@ -146,20 +146,20 @@ describe('fileHelpers', () => {
it('handles edge cases with empty strings', () => { it('handles edge cases with empty strings', () => {
expect(getFileUrl('', '')).toBe( expect(getFileUrl('', '')).toBe(
'http://localhost:8080/api/v1/workspaces//files/' 'http://localhost:8080/api/v1/workspaces//files/content?file_path='
); );
expect(getFileUrl('workspace', '')).toBe( expect(getFileUrl('workspace', '')).toBe(
'http://localhost:8080/api/v1/workspaces/workspace/files/' 'http://localhost:8080/api/v1/workspaces/workspace/files/content?file_path='
); );
expect(getFileUrl('', 'file.md')).toBe( expect(getFileUrl('', 'file.md')).toBe(
'http://localhost:8080/api/v1/workspaces//files/file.md' 'http://localhost:8080/api/v1/workspaces//files/content?file_path=file.md'
); );
}); });
it('uses the API base URL correctly', () => { it('uses the API base URL correctly', () => {
const url = getFileUrl('test', 'file.md'); const url = getFileUrl('test', 'file.md');
expect(url).toBe( expect(url).toBe(
'http://localhost:8080/api/v1/workspaces/test/files/file.md' 'http://localhost:8080/api/v1/workspaces/test/files/content?file_path=file.md'
); );
expect(url).toContain(window.API_BASE_URL); expect(url).toContain(window.API_BASE_URL);
}); });

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.
@@ -13,5 +25,67 @@ export const isImageFile = (filePath: string): boolean => {
export const getFileUrl = (workspaceName: string, filePath: string) => { export const getFileUrl = (workspaceName: string, filePath: string) => {
return `${API_BASE_URL}/workspaces/${encodeURIComponent( return `${API_BASE_URL}/workspaces/${encodeURIComponent(
workspaceName workspaceName
)}/files/${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,6 +240,29 @@ export function remarkWikiLinks(workspaceName: string) {
continue; continue;
} }
// If the filename contains a path separator, treat it as a full path
// This handles wikilinks with paths like [[folder/subfolder/file]]
let filePath: string;
if (match.fileName.includes('/')) {
// It's already a full path - use it directly
filePath = match.isImage ? match.fileName : addMarkdownExtension(match.fileName);
if (match.isImage) {
newNodes.push(
createImageNode(workspaceName, filePath, match.displayText)
);
} else {
newNodes.push(
createFileLink(
filePath,
match.displayText,
match.heading,
baseUrl
)
);
}
} else {
// It's just a filename - look it up to find the full path
const lookupFileName: string = match.isImage const lookupFileName: string = match.isImage
? match.fileName ? match.fileName
: addMarkdownExtension(match.fileName); : addMarkdownExtension(match.fileName);
@@ -250,7 +273,7 @@ export function remarkWikiLinks(workspaceName: string) {
); );
if (paths && paths.length > 0 && paths[0]) { if (paths && paths.length > 0 && paths[0]) {
const filePath: string = paths[0]; filePath = paths[0];
if (match.isImage) { if (match.isImage) {
newNodes.push( newNodes.push(
createImageNode(workspaceName, filePath, match.displayText) createImageNode(workspaceName, filePath, match.displayText)
@@ -270,6 +293,7 @@ export function remarkWikiLinks(workspaceName: string) {
createNotFoundLink(match.fileName, match.displayText, baseUrl) 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);
newNodes.push( newNodes.push(

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',
@@ -52,9 +55,8 @@ export default defineConfig(({ mode }) => ({
// Markdown processing // Markdown processing
markdown: [ markdown: [
'react-syntax-highlighter', 'rehype-highlight',
'rehype-mathjax', 'rehype-mathjax',
'rehype-prism',
'rehype-react', 'rehype-react',
'remark', 'remark',
'remark-math', 'remark-math',

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';

File diff suppressed because it is too large Load Diff

View File

@@ -1,63 +1,65 @@
module lemma module lemma
go 1.23.1 go 1.24.0
require ( require (
github.com/go-chi/chi/v5 v5.2.2 github.com/go-chi/chi/v5 v5.2.3
github.com/go-chi/cors v1.2.1 github.com/go-chi/cors v1.2.2
github.com/go-chi/httprate v0.14.1 github.com/go-chi/httprate v0.15.0
github.com/go-git/go-git/v5 v5.13.1 github.com/go-git/go-git/v5 v5.16.3
github.com/go-playground/validator/v10 v10.22.1 github.com/go-playground/validator/v10 v10.28.0
github.com/golang-jwt/jwt/v5 v5.2.2 github.com/golang-jwt/jwt/v5 v5.3.0
github.com/golang-migrate/migrate/v4 v4.18.2 github.com/golang-migrate/migrate/v4 v4.19.0
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/lib/pq v1.10.9 github.com/lib/pq v1.10.9
github.com/mattn/go-sqlite3 v1.14.23 github.com/mattn/go-sqlite3 v1.14.32
github.com/stretchr/testify v1.10.0 github.com/stretchr/testify v1.11.1
github.com/swaggo/http-swagger v1.3.4 github.com/swaggo/http-swagger v1.3.4
github.com/swaggo/swag v1.16.4 github.com/swaggo/swag v1.16.6
github.com/unrolled/secure v1.17.0 github.com/unrolled/secure v1.17.0
golang.org/x/crypto v0.36.0 golang.org/x/crypto v0.42.0
) )
require ( require (
dario.cat/mergo v1.0.0 // indirect dario.cat/mergo v1.0.0 // indirect
github.com/KyleBanks/depth v1.2.1 // indirect github.com/KyleBanks/depth v1.2.1 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/ProtonMail/go-crypto v1.1.3 // indirect github.com/ProtonMail/go-crypto v1.1.6 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudflare/circl v1.6.1 // indirect github.com/cloudflare/circl v1.6.1 // indirect
github.com/cyphar/filepath-securejoin v0.3.6 // indirect github.com/cyphar/filepath-securejoin v0.4.1 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect
github.com/emirpasic/gods v1.18.1 // indirect github.com/emirpasic/gods v1.18.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect github.com/gabriel-vasile/mimetype v1.4.10 // indirect
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
github.com/go-git/go-billy/v5 v5.6.1 // indirect github.com/go-git/go-billy/v5 v5.6.2 // indirect
github.com/go-openapi/jsonpointer v0.19.5 // indirect github.com/go-openapi/jsonpointer v0.19.5 // indirect
github.com/go-openapi/jsonreference v0.20.0 // indirect github.com/go-openapi/jsonreference v0.20.0 // indirect
github.com/go-openapi/spec v0.20.6 // indirect github.com/go-openapi/spec v0.20.6 // indirect
github.com/go-openapi/swag v0.19.15 // indirect github.com/go-openapi/swag v0.19.15 // indirect
github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
github.com/josharian/intern v1.0.0 // indirect github.com/josharian/intern v1.0.0 // indirect
github.com/kevinburke/ssh_config v1.2.0 // indirect github.com/kevinburke/ssh_config v1.2.0 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
github.com/leodido/go-urn v1.4.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect
github.com/mailru/easyjson v0.7.6 // indirect github.com/mailru/easyjson v0.7.6 // indirect
github.com/pjbgf/sha1cd v0.3.0 // indirect github.com/pjbgf/sha1cd v0.3.2 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
github.com/skeema/knownhosts v1.3.0 // indirect github.com/skeema/knownhosts v1.3.1 // indirect
github.com/swaggo/files v0.0.0-20220610200504-28940afbdbfe // indirect github.com/swaggo/files v0.0.0-20220610200504-28940afbdbfe // indirect
github.com/xanzy/ssh-agent v0.3.3 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect
go.uber.org/atomic v1.7.0 // indirect github.com/zeebo/xxh3 v1.0.2 // indirect
golang.org/x/net v0.38.0 // indirect golang.org/x/mod v0.27.0 // indirect
golang.org/x/sys v0.31.0 // indirect golang.org/x/net v0.43.0 // indirect
golang.org/x/text v0.23.0 // indirect golang.org/x/sync v0.17.0 // indirect
golang.org/x/tools v0.24.0 // indirect golang.org/x/sys v0.36.0 // indirect
golang.org/x/text v0.29.0 // indirect
golang.org/x/tools v0.36.0 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect

View File

@@ -7,58 +7,60 @@ github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6Xge
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/ProtonMail/go-crypto v1.1.3 h1:nRBOetoydLeUb4nHajyO2bKqMLfWQ/ZPwkXqXxPxCFk= github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw=
github.com/ProtonMail/go-crypto v1.1.3/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0= github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0=
github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/cyphar/filepath-securejoin v0.3.6 h1:4d9N5ykBnSp5Xn2JkhocYDkOpURL/18CYMpo6xB9uWM= github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s=
github.com/cyphar/filepath-securejoin v0.3.6/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI= github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dhui/dktest v0.4.4 h1:+I4s6JRE1yGuqflzwqG+aIaMdgXIorCf5P98JnaAWa8= github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4=
github.com/dhui/dktest v0.4.4/go.mod h1:4+22R4lgsdAXrDyaH4Nqx2JEz2hLp49MqQmm9HLCQhM= github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/docker v27.2.0+incompatible h1:Rk9nIVdfH3+Vz4cyI/uhbINhEZ/oLmc+CBXmH6fbNk4= github.com/docker/docker v28.3.3+incompatible h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI=
github.com/docker/docker v27.2.0+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= github.com/docker/docker v28.3.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/elazarl/goproxy v1.2.3 h1:xwIyKHbaP5yfT6O9KIeYJR5549MXRQkoQMRXGztz8YQ= github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o=
github.com/elazarl/goproxy v1.2.3/go.mod h1:YfEbZtqP4AetfO6d40vWchF3znWX7C7Vd6ZMfdL8z64= github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0=
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
github.com/go-chi/chi/v5 v5.2.2 h1:CMwsvRVTbXVytCk1Wd72Zy1LAsAh9GxMmSNWLHCG618= github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
github.com/go-chi/chi/v5 v5.2.2/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4= github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
github.com/go-chi/httprate v0.14.1 h1:EKZHYEZ58Cg6hWcYzoZILsv7ppb46Wt4uQ738IRtpZs= github.com/go-chi/httprate v0.15.0 h1:j54xcWV9KGmPf/X4H32/aTH+wBlrvxL7P+SdnRqxh5g=
github.com/go-chi/httprate v0.14.1/go.mod h1:TUepLXaz/pCjmCtf/obgOQJ2Sz6rC8fSf5cAt5cnTt0= github.com/go-chi/httprate v0.15.0/go.mod h1:rzGHhVrsBn3IMLYDOZQsSU4fJNWcjui4fWKJcCId1R4=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
github.com/go-git/go-billy/v5 v5.6.1 h1:u+dcrgaguSSkbjzHwelEjc0Yj300NUevrrPphk/SoRA= github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM=
github.com/go-git/go-billy/v5 v5.6.1/go.mod h1:0AsLr1z2+Uksi4NlElmMblP5rPcDZNRCD8ujZCRR2BE= github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU=
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4= github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4=
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
github.com/go-git/go-git/v5 v5.13.1 h1:DAQ9APonnlvSWpvolXWIuV6Q6zXy2wHbN4cVlNR5Q+M= github.com/go-git/go-git/v5 v5.16.3 h1:Z8BtvxZ09bYm/yYNgPKCzgWtaRqDTgIKRgIRHBfU6Z8=
github.com/go-git/go-git/v5 v5.13.1/go.mod h1:qryJB4cSBoq3FRoBRf5A77joojuBcmPJ0qu3XXXVixc= github.com/go-git/go-git/v5 v5.16.3/go.mod h1:4Ge4alE/5gPs30F2H1esi2gPd69R0C39lolkucHBOp8=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
@@ -77,18 +79,18 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.22.1 h1:40JcKH+bBNGFczGuoBYgX4I6m/i27HYW8P9FDk5PbgA= github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688=
github.com/go-playground/validator/v10 v10.22.1/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang-migrate/migrate/v4 v4.18.2 h1:2VSCMz7x7mjyTXx3m2zPokOY82LTRgxK1yQYKo6wWQ8= github.com/golang-migrate/migrate/v4 v4.19.0 h1:RcjOnCGz3Or6HQYEJ/EEVLfWnmw9KnoigPSjzhCuaSE=
github.com/golang-migrate/migrate/v4 v4.18.2/go.mod h1:2CM6tJvn2kqPXwnXO/d3rAQYiyoIm180VsO8PRX6Rpk= github.com/golang-migrate/migrate/v4 v4.19.0/go.mod h1:9dyEcu+hO+G9hPSw8AIg50yg622pXJsoHItQnDGZkI0=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
@@ -102,6 +104,8 @@ github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8Hm
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4=
github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
@@ -117,8 +121,8 @@ github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN
github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA= github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA=
github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mattn/go-sqlite3 v1.14.23 h1:gbShiuAP1W5j9UOksQ06aiiqPMxYecovVGwmTxWtuw0= github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
github.com/mattn/go-sqlite3 v1.14.23/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
@@ -132,76 +136,80 @@ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
github.com/pjbgf/sha1cd v0.3.0 h1:4D5XXmUUBUl/xQ6IjCkEAbqXskkq/4O7LmGn0AqMDs4= github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4=
github.com/pjbgf/sha1cd v0.3.0/go.mod h1:nZ1rrWOcGJ5uZgEEVL1VUM9iRQiZvWdbZjkKyFzPPsI= github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8=
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
github.com/skeema/knownhosts v1.3.0 h1:AM+y0rI04VksttfwjkSTNQorvGqmwATnvnAHpSgc0LY= github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8=
github.com/skeema/knownhosts v1.3.0/go.mod h1:sPINvnADmT/qYH1kfv+ePMmOBTH6Tbl7b5LvTDjFK7M= github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/swaggo/files v0.0.0-20220610200504-28940afbdbfe h1:K8pHPVoTgxFJt1lXuIzzOX7zZhZFldJQK/CgKx9BFIc= github.com/swaggo/files v0.0.0-20220610200504-28940afbdbfe h1:K8pHPVoTgxFJt1lXuIzzOX7zZhZFldJQK/CgKx9BFIc=
github.com/swaggo/files v0.0.0-20220610200504-28940afbdbfe/go.mod h1:lKJPbtWzJ9JhsTN1k1gZgleJWY/cqq0psdoMmaThG3w= github.com/swaggo/files v0.0.0-20220610200504-28940afbdbfe/go.mod h1:lKJPbtWzJ9JhsTN1k1gZgleJWY/cqq0psdoMmaThG3w=
github.com/swaggo/http-swagger v1.3.4 h1:q7t/XLx0n15H1Q9/tk3Y9L4n210XzJF5WtnDX64a5ww= github.com/swaggo/http-swagger v1.3.4 h1:q7t/XLx0n15H1Q9/tk3Y9L4n210XzJF5WtnDX64a5ww=
github.com/swaggo/http-swagger v1.3.4/go.mod h1:9dAh0unqMBAlbp1uE2Uc2mQTxNMU/ha4UbucIg1MFkQ= github.com/swaggo/http-swagger v1.3.4/go.mod h1:9dAh0unqMBAlbp1uE2Uc2mQTxNMU/ha4UbucIg1MFkQ=
github.com/swaggo/swag v1.16.4 h1:clWJtd9LStiG3VeijiCfOVODP6VpHtKdQy9ELFG3s1A= github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI=
github.com/swaggo/swag v1.16.4/go.mod h1:VBsHJRsDvfYvqoiMKnsdwhNV9LEMHgEDZcyVYX0sxPg= github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
github.com/unrolled/secure v1.17.0 h1:Io7ifFgo99Bnh0J7+Q+qcMzWM6kaDPCA5FroFZEdbWU= github.com/unrolled/secure v1.17.0 h1:Io7ifFgo99Bnh0J7+Q+qcMzWM6kaDPCA5FroFZEdbWU=
github.com/unrolled/secure v1.17.0/go.mod h1:BmF5hyM6tXczk3MpQkFf1hpKSRqCyhqcbiQtiAF7+40= github.com/unrolled/secure v1.17.0/go.mod h1:BmF5hyM6tXczk3MpQkFf1hpKSRqCyhqcbiQtiAF7+40=
github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8=
go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw= go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8= go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
go.opentelemetry.io/otel/metric v1.29.0 h1:vPf/HFWTNkPu1aYeIsc98l4ktOQaL6LeSoeV2g+8YLc= go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
go.opentelemetry.io/otel/metric v1.29.0/go.mod h1:auu/QWieFVWx+DmQOUMgj0F8LHWdgalxXqvp7BII/W8= go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4= go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ= go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw=
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
golang.org/x/mod v0.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0= golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y= golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ=
golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g= golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.24.0 h1:J1shsA93PJUEVaUSaay7UXAyE8aimq3GW0pjlolpa24= golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
golang.org/x/tools v0.24.0/go.mod h1:YhNqVBIfWHdzvTLs0d8LCuMhkKUgSUKldakyV7W/WDQ= golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

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

@@ -64,7 +64,9 @@ func setupRouter(o Options) *chi.Mux {
// API routes // API routes
r.Route("/api/v1", func(r chi.Router) { r.Route("/api/v1", func(r chi.Router) {
// Rate limiting for API routes // Public routes (no authentication required)
r.Group(func(r chi.Router) {
// Rate limiting for authentication endpoints to prevent brute force attacks
if o.Config.RateLimitRequests > 0 { if o.Config.RateLimitRequests > 0 {
r.Use(httprate.LimitByIP( r.Use(httprate.LimitByIP(
o.Config.RateLimitRequests, o.Config.RateLimitRequests,
@@ -72,8 +74,6 @@ func setupRouter(o Options) *chi.Mux {
)) ))
} }
// Public routes (no authentication required)
r.Group(func(r chi.Router) {
r.Post("/auth/login", handler.Login(o.SessionManager, o.CookieService)) r.Post("/auth/login", handler.Login(o.SessionManager, o.CookieService))
r.Post("/auth/refresh", handler.RefreshToken(o.SessionManager, o.CookieService)) r.Post("/auth/refresh", handler.RefreshToken(o.SessionManager, o.CookieService))
}) })
@@ -134,7 +134,7 @@ func setupRouter(o Options) *chi.Mux {
r.Get("/lookup", handler.LookupFileByName()) r.Get("/lookup", handler.LookupFileByName())
r.Post("/upload", handler.UploadFile()) r.Post("/upload", handler.UploadFile())
r.Put("/move", handler.MoveFile()) r.Post("/move", handler.MoveFile())
r.Post("/", handler.SaveFile()) r.Post("/", handler.SaveFile())
r.Get("/content", handler.GetFileContent()) r.Get("/content", handler.GetFileContent())
@@ -152,7 +152,9 @@ func setupRouter(o Options) *chi.Mux {
}) })
// Handle all other routes with static file server // Handle all other routes with static file server
r.Get("/*", handlers.NewStaticHandler(o.Config.StaticPath).ServeHTTP) staticHandler := handlers.NewStaticHandler(o.Config.StaticPath)
r.Get("/*", staticHandler.ServeHTTP)
r.Head("/*", staticHandler.ServeHTTP)
return r return r
} }

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

@@ -2,9 +2,11 @@ package handlers
import ( import (
"io" "io"
"mime"
"net/http" "net/http"
"net/url" "net/url"
"os" "os"
"path/filepath"
"time" "time"
"lemma/internal/context" "lemma/internal/context"
@@ -205,7 +207,13 @@ func (h *Handler) GetFileContent() http.HandlerFunc {
return return
} }
w.Header().Set("Content-Type", "text/plain") // Detect MIME type based on file extension
contentType := mime.TypeByExtension(filepath.Ext(decodedPath))
if contentType == "" {
// Fallback to text/plain if MIME type cannot be determined
contentType = "text/plain"
}
w.Header().Set("Content-Type", contentType)
_, err = w.Write(content) _, err = w.Write(content)
if err != nil { if err != nil {
log.Error("failed to write response", log.Error("failed to write response",
@@ -402,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

@@ -156,6 +156,54 @@ func testFileHandlers(t *testing.T, dbConfig DatabaseConfig) {
assert.Equal(t, http.StatusNotFound, rr.Code) assert.Equal(t, http.StatusNotFound, rr.Code)
}) })
t.Run("move file", func(t *testing.T) {
srcPath := "original.md"
destPath := "moved.md"
content := "This file will be moved"
// Create file
rr := h.makeRequestRaw(t, http.MethodPost, baseURL+"?file_path="+url.QueryEscape(srcPath), strings.NewReader(content), h.RegularTestUser)
require.Equal(t, http.StatusOK, rr.Code)
// Move file
moveURL := baseURL + "/move?src_path=" + url.QueryEscape(srcPath) + "&dest_path=" + url.QueryEscape(destPath)
rr = h.makeRequest(t, http.MethodPost, moveURL, nil, h.RegularTestUser)
require.Equal(t, http.StatusOK, rr.Code)
// Verify source is gone
rr = h.makeRequest(t, http.MethodGet, baseURL+"/content?file_path="+url.QueryEscape(srcPath), nil, h.RegularTestUser)
assert.Equal(t, http.StatusNotFound, rr.Code)
// Verify destination exists with correct content
rr = h.makeRequest(t, http.MethodGet, baseURL+"/content?file_path="+url.QueryEscape(destPath), nil, h.RegularTestUser)
require.Equal(t, http.StatusOK, rr.Code)
assert.Equal(t, content, rr.Body.String())
})
t.Run("rename file in directory", func(t *testing.T) {
srcPath := "folder/old-name.md"
destPath := "folder/new-name.md"
content := "This file will be renamed"
// Create file
rr := h.makeRequestRaw(t, http.MethodPost, baseURL+"?file_path="+url.QueryEscape(srcPath), strings.NewReader(content), h.RegularTestUser)
require.Equal(t, http.StatusOK, rr.Code)
// Rename file (move within same directory)
moveURL := baseURL + "/move?src_path=" + url.QueryEscape(srcPath) + "&dest_path=" + url.QueryEscape(destPath)
rr = h.makeRequest(t, http.MethodPost, moveURL, nil, h.RegularTestUser)
require.Equal(t, http.StatusOK, rr.Code)
// Verify source is gone
rr = h.makeRequest(t, http.MethodGet, baseURL+"/content?file_path="+url.QueryEscape(srcPath), nil, h.RegularTestUser)
assert.Equal(t, http.StatusNotFound, rr.Code)
// Verify destination exists with correct content
rr = h.makeRequest(t, http.MethodGet, baseURL+"/content?file_path="+url.QueryEscape(destPath), nil, h.RegularTestUser)
require.Equal(t, http.StatusOK, rr.Code)
assert.Equal(t, content, rr.Body.String())
})
t.Run("last opened file", func(t *testing.T) { t.Run("last opened file", func(t *testing.T) {
// Initially should be empty // Initially should be empty
rr := h.makeRequest(t, http.MethodGet, baseURL+"/last", nil, h.RegularTestUser) rr := h.makeRequest(t, http.MethodGet, baseURL+"/last", nil, h.RegularTestUser)
@@ -304,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

@@ -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