mirror of
https://github.com/aljazceru/claude-code-viewer.git
synced 2025-12-26 01:34:21 +01:00
Merge branch 'main' into fix/handle-missing-claude-directory
This commit is contained in:
45
.github/workflows/ci.yml
vendored
Normal file
45
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,45 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Quality Checks
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10.8.1
|
||||
|
||||
- name: Setup Node.js 20.12.0
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20.12.0
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Run linting
|
||||
run: pnpm lint
|
||||
|
||||
- name: Run type checking
|
||||
run: pnpm typecheck
|
||||
|
||||
- name: Build project
|
||||
run: pnpm build
|
||||
@@ -1 +1 @@
|
||||
24.4.1
|
||||
20.12.0
|
||||
|
||||
35
CHANGELOG.md
35
CHANGELOG.md
@@ -1,5 +1,40 @@
|
||||
# Changelog
|
||||
|
||||
## 0.3.1
|
||||
|
||||
### Features
|
||||
|
||||
- Add configurable Enter key behavior for message input - by **nepula_h_okuyama** and **Claude** [<samp>(e37ca)</samp>](https://github.com/d-kimuson/claude-code-viewer/commit/e37ca87)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Resolve lint and formatting errors - by **amay077** and **Claude** [<samp>(730d1)</samp>](https://github.com/d-kimuson/claude-code-viewer/commit/730d134)
|
||||
|
||||
##### [View changes on GitHub](https://github.com/d-kimuson/claude-code-viewer/compare/v0.3.0...0.3.1)
|
||||
|
||||
## 0.3.0
|
||||
|
||||
### Features
|
||||
|
||||
- Set timeout for new-chat & resume-chat - by **d-kimsuon** [<samp>(d0fda)</samp>](https://github.com/d-kimuson/claude-code-viewer/commit/d0fdade)
|
||||
- Add @ file completion - by **d-kimsuon** [<samp>(60aaa)</samp>](https://github.com/d-kimuson/claude-code-viewer/commit/60aaae7)
|
||||
- Inline completion for command and files - by **d-kimsuon** [<samp>(e90dc)</samp>](https://github.com/d-kimuson/claude-code-viewer/commit/e90dc00)
|
||||
- Fix out of style - by **d-kimsuon** [<samp>(7fafb)</samp>](https://github.com/d-kimuson/claude-code-viewer/commit/7fafb18)
|
||||
- Add simple git diff preview modal - by **d-kimsuon** [<samp>(c5688)</samp>](https://github.com/d-kimuson/claude-code-viewer/commit/c568831)
|
||||
- Add comprehensive CI workflow for quality checks - by **d-kimsuon** and **Claude** [<samp>(580e5)</samp>](https://github.com/d-kimuson/claude-code-viewer/commit/580e51f)
|
||||
- Add notification when task paused - by **d-kimsuon** [<samp>(8b6b0)</samp>](https://github.com/d-kimuson/claude-code-viewer/commit/8b6b03b)
|
||||
- Add sonner message on task completed - by **d-kimsuon** [<samp>(a3e6f)</samp>](https://github.com/d-kimuson/claude-code-viewer/commit/a3e6feb)
|
||||
- **diff-view**: Display untacked added file - by **d-kimsuon** [<samp>(e7c3c)</samp>](https://github.com/d-kimuson/claude-code-viewer/commit/e7c3c87)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Bug fix session list doesn't updated after filter config changed - by **d-kimsuon** [<samp>(52a23)</samp>](https://github.com/d-kimuson/claude-code-viewer/commit/52a231b)
|
||||
- Fix header text content overflow - by **d-kimsuon** [<samp>(a618e)</samp>](https://github.com/d-kimuson/claude-code-viewer/commit/a618e24)
|
||||
- Bug fix that input message gone out though new chat is not sent yet - by **d-kimsuon** [<samp>(ca316)</samp>](https://github.com/d-kimuson/claude-code-viewer/commit/ca31602)
|
||||
- Add unsupported container property to schema - by **d-kimsuon** [<samp>(c7a1e)</samp>](https://github.com/d-kimuson/claude-code-viewer/commit/c7a1e6d)
|
||||
|
||||
##### [View changes on GitHub](https://github.com/d-kimuson/claude-code-viewer/compare/v0.2.4...0.3.0)
|
||||
|
||||
## 0.2.4
|
||||
|
||||
### Features
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@kimuson/claude-code-viewer",
|
||||
"version": "0.2.4",
|
||||
"version": "0.3.1",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
@@ -44,6 +44,7 @@
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-hover-card": "^1.1.15",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@tanstack/react-query": "^5.85.5",
|
||||
@@ -53,6 +54,8 @@
|
||||
"jotai": "^2.13.1",
|
||||
"lucide-react": "^0.542.0",
|
||||
"next": "15.5.2",
|
||||
"next-themes": "^0.4.6",
|
||||
"parse-git-diff": "^0.0.19",
|
||||
"prexit": "^2.3.0",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
@@ -60,6 +63,7 @@
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-syntax-highlighter": "^15.6.6",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"ulid": "^3.0.1",
|
||||
"zod": "^4.1.5"
|
||||
|
||||
108
pnpm-lock.yaml
generated
108
pnpm-lock.yaml
generated
@@ -29,6 +29,9 @@ importers:
|
||||
'@radix-ui/react-hover-card':
|
||||
specifier: ^1.1.15
|
||||
version: 1.1.15(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
|
||||
'@radix-ui/react-select':
|
||||
specifier: ^2.2.6
|
||||
version: 2.2.6(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
|
||||
'@radix-ui/react-slot':
|
||||
specifier: ^1.2.3
|
||||
version: 1.2.3(@types/react@19.1.12)(react@19.1.1)
|
||||
@@ -56,6 +59,12 @@ importers:
|
||||
next:
|
||||
specifier: 15.5.2
|
||||
version: 15.5.2(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
|
||||
next-themes:
|
||||
specifier: ^0.4.6
|
||||
version: 0.4.6(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
|
||||
parse-git-diff:
|
||||
specifier: ^0.0.19
|
||||
version: 0.0.19
|
||||
prexit:
|
||||
specifier: ^2.3.0
|
||||
version: 2.3.0
|
||||
@@ -77,6 +86,9 @@ importers:
|
||||
remark-gfm:
|
||||
specifier: ^4.0.1
|
||||
version: 4.0.1
|
||||
sonner:
|
||||
specifier: ^2.0.7
|
||||
version: 2.0.7(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
|
||||
tailwind-merge:
|
||||
specifier: ^3.3.1
|
||||
version: 3.3.1
|
||||
@@ -842,6 +854,9 @@ packages:
|
||||
resolution: {integrity: sha512-7J6ca1tK0duM2BgVB+CuFMh3idlIVASOP2QvOCbNWDc6JnvjtKa9nufPoJQQ4xrwBonwgT1TIhRRcEtzdVgWsA==}
|
||||
engines: {node: ^20.9.0 || >=22.0.0, npm: '>=10.8.2'}
|
||||
|
||||
'@radix-ui/number@1.1.1':
|
||||
resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==}
|
||||
|
||||
'@radix-ui/primitive@1.1.3':
|
||||
resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==}
|
||||
|
||||
@@ -1072,6 +1087,19 @@ packages:
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-select@2.2.6':
|
||||
resolution: {integrity: sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-slot@1.2.3':
|
||||
resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==}
|
||||
peerDependencies:
|
||||
@@ -1175,6 +1203,19 @@ packages:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-visually-hidden@1.2.3':
|
||||
resolution: {integrity: sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/rect@1.1.1':
|
||||
resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==}
|
||||
|
||||
@@ -2517,6 +2558,12 @@ packages:
|
||||
resolution: {integrity: sha512-NHDDGYudnvRutt/VhKFlX26IotXe1w0cmkDm6JGquh5bz/bDTw0LufSmH/GxTjEdpHEO+bVKFTwdrcGa/9XlKQ==}
|
||||
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
||||
|
||||
next-themes@0.4.6:
|
||||
resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==}
|
||||
peerDependencies:
|
||||
react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
|
||||
|
||||
next@15.5.2:
|
||||
resolution: {integrity: sha512-H8Otr7abj1glFhbGnvUt3gz++0AF1+QoCXEBmd/6aKbfdFwrn0LpA836Ed5+00va/7HQSDD+mOoVhn3tNy3e/Q==}
|
||||
engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0}
|
||||
@@ -2618,6 +2665,9 @@ packages:
|
||||
parse-entities@4.0.2:
|
||||
resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==}
|
||||
|
||||
parse-git-diff@0.0.19:
|
||||
resolution: {integrity: sha512-oh3giwKzsPlOhekiDDyd/pfFKn04IZoTjEThquhfKigwiUHymiP/Tp6AN5nGIwXQdWuBTQvz9AaRdN5TBsJ8MA==}
|
||||
|
||||
parse-ms@4.0.0:
|
||||
resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -2883,6 +2933,12 @@ packages:
|
||||
resolution: {integrity: sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==}
|
||||
engines: {node: '>= 10.0.0', npm: '>= 3.0.0'}
|
||||
|
||||
sonner@2.0.7:
|
||||
resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==}
|
||||
peerDependencies:
|
||||
react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
|
||||
react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
|
||||
|
||||
source-map-js@1.2.1:
|
||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -3800,6 +3856,8 @@ snapshots:
|
||||
|
||||
'@phun-ky/typeof@1.2.8': {}
|
||||
|
||||
'@radix-ui/number@1.1.1': {}
|
||||
|
||||
'@radix-ui/primitive@1.1.3': {}
|
||||
|
||||
'@radix-ui/react-arrow@1.1.7(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)':
|
||||
@@ -4026,6 +4084,35 @@ snapshots:
|
||||
'@types/react': 19.1.12
|
||||
'@types/react-dom': 19.1.9(@types/react@19.1.12)
|
||||
|
||||
'@radix-ui/react-select@2.2.6(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)':
|
||||
dependencies:
|
||||
'@radix-ui/number': 1.1.1
|
||||
'@radix-ui/primitive': 1.1.3
|
||||
'@radix-ui/react-collection': 1.1.7(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.12)(react@19.1.1)
|
||||
'@radix-ui/react-context': 1.1.2(@types/react@19.1.12)(react@19.1.1)
|
||||
'@radix-ui/react-direction': 1.1.1(@types/react@19.1.12)(react@19.1.1)
|
||||
'@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
|
||||
'@radix-ui/react-focus-guards': 1.1.3(@types/react@19.1.12)(react@19.1.1)
|
||||
'@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
|
||||
'@radix-ui/react-id': 1.1.1(@types/react@19.1.12)(react@19.1.1)
|
||||
'@radix-ui/react-popper': 1.2.8(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
|
||||
'@radix-ui/react-portal': 1.1.9(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
|
||||
'@radix-ui/react-slot': 1.2.3(@types/react@19.1.12)(react@19.1.1)
|
||||
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.12)(react@19.1.1)
|
||||
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.12)(react@19.1.1)
|
||||
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.12)(react@19.1.1)
|
||||
'@radix-ui/react-use-previous': 1.1.1(@types/react@19.1.12)(react@19.1.1)
|
||||
'@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
|
||||
aria-hidden: 1.2.6
|
||||
react: 19.1.1
|
||||
react-dom: 19.1.1(react@19.1.1)
|
||||
react-remove-scroll: 2.7.1(@types/react@19.1.12)(react@19.1.1)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.1.12
|
||||
'@types/react-dom': 19.1.9(@types/react@19.1.12)
|
||||
|
||||
'@radix-ui/react-slot@1.2.3(@types/react@19.1.12)(react@19.1.1)':
|
||||
dependencies:
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.12)(react@19.1.1)
|
||||
@@ -4110,6 +4197,15 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@types/react': 19.1.12
|
||||
|
||||
'@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)':
|
||||
dependencies:
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
|
||||
react: 19.1.1
|
||||
react-dom: 19.1.1(react@19.1.1)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.1.12
|
||||
'@types/react-dom': 19.1.9(@types/react@19.1.12)
|
||||
|
||||
'@radix-ui/rect@1.1.1': {}
|
||||
|
||||
'@rollup/rollup-android-arm-eabi@4.49.0':
|
||||
@@ -5567,6 +5663,11 @@ snapshots:
|
||||
dependencies:
|
||||
type-fest: 2.19.0
|
||||
|
||||
next-themes@0.4.6(react-dom@19.1.1(react@19.1.1))(react@19.1.1):
|
||||
dependencies:
|
||||
react: 19.1.1
|
||||
react-dom: 19.1.1(react@19.1.1)
|
||||
|
||||
next@15.5.2(react-dom@19.1.1(react@19.1.1))(react@19.1.1):
|
||||
dependencies:
|
||||
'@next/env': 15.5.2
|
||||
@@ -5714,6 +5815,8 @@ snapshots:
|
||||
is-decimal: 2.0.1
|
||||
is-hexadecimal: 2.0.1
|
||||
|
||||
parse-git-diff@0.0.19: {}
|
||||
|
||||
parse-ms@4.0.0: {}
|
||||
|
||||
parse-path@7.1.0:
|
||||
@@ -6078,6 +6181,11 @@ snapshots:
|
||||
ip-address: 10.0.1
|
||||
smart-buffer: 4.2.0
|
||||
|
||||
sonner@2.0.7(react-dom@19.1.1(react@19.1.1))(react@19.1.1):
|
||||
dependencies:
|
||||
react: 19.1.1
|
||||
react-dom: 19.1.1(react@19.1.1)
|
||||
|
||||
source-map-js@1.2.1: {}
|
||||
|
||||
source-map-support@0.5.21:
|
||||
|
||||
@@ -84,7 +84,10 @@ export const MarkdownContent: FC<MarkdownContentProps> = ({
|
||||
},
|
||||
p({ children, ...props }) {
|
||||
return (
|
||||
<p className="mb-4 leading-7 text-foreground" {...props}>
|
||||
<p
|
||||
className="mb-4 leading-7 text-foreground break-all"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
@@ -117,7 +120,7 @@ export const MarkdownContent: FC<MarkdownContentProps> = ({
|
||||
if (isInline) {
|
||||
return (
|
||||
<code
|
||||
className="bg-muted/70 px-2 py-1 rounded-md text-sm font-mono text-foreground border"
|
||||
className="bg-muted/70 px-2 py-1 rounded-md text-sm font-mono text-foreground border break-all"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
@@ -175,8 +178,8 @@ export const MarkdownContent: FC<MarkdownContentProps> = ({
|
||||
// テーブルの改善
|
||||
table({ children, ...props }) {
|
||||
return (
|
||||
<div className="overflow-x-auto my-6 rounded-lg border border-border">
|
||||
<table className="min-w-full border-collapse" {...props}>
|
||||
<div className="overflow-x-auto my-6 rounded-lg border border-border max-w-full">
|
||||
<table className="w-full border-collapse" {...props}>
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import { Toaster } from "../components/ui/sonner";
|
||||
import { QueryClientProviderWrapper } from "../lib/api/QueryClientProviderWrapper";
|
||||
import { RootErrorBoundary } from "./components/RootErrorBoundary";
|
||||
import { ServerEventsProvider } from "./components/ServerEventsProvider";
|
||||
@@ -47,6 +48,7 @@ export default async function RootLayout({
|
||||
<ServerEventsProvider>{children}</ServerEventsProvider>
|
||||
</QueryClientProviderWrapper>
|
||||
</RootErrorBoundary>
|
||||
<Toaster position="top-right" />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -45,12 +45,6 @@ export const ProjectPageContent = ({ projectId }: { projectId: string }) => {
|
||||
});
|
||||
}, [config.hideNoUserMessageSession, config.unifySameTitleSession]);
|
||||
|
||||
const handleConfigChange = () => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: projectQueryConfig(projectId).queryKey,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-2 sm:px-4 py-4 sm:py-8 max-w-6xl">
|
||||
<header className="mb-6 sm:mb-8">
|
||||
@@ -118,7 +112,7 @@ export const ProjectPageContent = ({ projectId }: { projectId: string }) => {
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div className="p-4 bg-muted/50 rounded-lg border">
|
||||
<SettingsControls onConfigChange={handleConfigChange} />
|
||||
<SettingsControls openingProjectId={projectId} />
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</div>
|
||||
|
||||
226
src/app/projects/[projectId]/components/chatForm/ChatInput.tsx
Normal file
226
src/app/projects/[projectId]/components/chatForm/ChatInput.tsx
Normal file
@@ -0,0 +1,226 @@
|
||||
import { AlertCircleIcon, LoaderIcon, SendIcon } from "lucide-react";
|
||||
import { type FC, useCallback, useId, useRef, useState } from "react";
|
||||
import { Button } from "../../../../../components/ui/button";
|
||||
import { Textarea } from "../../../../../components/ui/textarea";
|
||||
import { useConfig } from "../../../../hooks/useConfig";
|
||||
import type { CommandCompletionRef } from "./CommandCompletion";
|
||||
import type { FileCompletionRef } from "./FileCompletion";
|
||||
import { InlineCompletion } from "./InlineCompletion";
|
||||
|
||||
export interface ChatInputProps {
|
||||
projectId: string;
|
||||
onSubmit: (message: string) => Promise<void>;
|
||||
isPending: boolean;
|
||||
error?: Error | null;
|
||||
placeholder: string;
|
||||
buttonText: string;
|
||||
minHeight?: string;
|
||||
containerClassName?: string;
|
||||
disabled?: boolean;
|
||||
buttonSize?: "sm" | "default" | "lg";
|
||||
}
|
||||
|
||||
export const ChatInput: FC<ChatInputProps> = ({
|
||||
projectId,
|
||||
onSubmit,
|
||||
isPending,
|
||||
error,
|
||||
placeholder,
|
||||
buttonText,
|
||||
minHeight = "min-h-[100px]",
|
||||
containerClassName = "",
|
||||
disabled = false,
|
||||
buttonSize = "lg",
|
||||
}) => {
|
||||
const [message, setMessage] = useState("");
|
||||
const [cursorPosition, setCursorPosition] = useState<{
|
||||
relative: { top: number; left: number };
|
||||
absolute: { top: number; left: number };
|
||||
}>({ relative: { top: 0, left: 0 }, absolute: { top: 0, left: 0 } });
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const commandCompletionRef = useRef<CommandCompletionRef>(null);
|
||||
const fileCompletionRef = useRef<FileCompletionRef>(null);
|
||||
const helpId = useId();
|
||||
const { config } = useConfig();
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!message.trim()) return;
|
||||
await onSubmit(message.trim());
|
||||
setMessage("");
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (fileCompletionRef.current?.handleKeyDown(e)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (commandCompletionRef.current?.handleKeyDown(e)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// IMEで変換中の場合は送信しない
|
||||
if (e.key === "Enter" && !e.nativeEvent.isComposing) {
|
||||
const isEnterSend = config?.enterKeyBehavior === "enter-send";
|
||||
|
||||
if (isEnterSend && !e.shiftKey) {
|
||||
// Enter: Send mode
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
} else if (!isEnterSend && e.shiftKey) {
|
||||
// Shift+Enter: Send mode (default)
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getCursorPosition = useCallback(() => {
|
||||
const textarea = textareaRef.current;
|
||||
const container = containerRef.current;
|
||||
if (textarea === null || container === null) return undefined;
|
||||
|
||||
const cursorPos = textarea.selectionStart;
|
||||
const textBeforeCursor = textarea.value.substring(0, cursorPos);
|
||||
const textAfterCursor = textarea.value.substring(cursorPos);
|
||||
|
||||
const pre = document.createTextNode(textBeforeCursor);
|
||||
const post = document.createTextNode(textAfterCursor);
|
||||
const caret = document.createElement("span");
|
||||
caret.innerHTML = " ";
|
||||
|
||||
const mirrored = document.createElement("div");
|
||||
|
||||
mirrored.innerHTML = "";
|
||||
mirrored.append(pre, caret, post);
|
||||
|
||||
const textareaStyles = window.getComputedStyle(textarea);
|
||||
for (const property of [
|
||||
"border",
|
||||
"boxSizing",
|
||||
"fontFamily",
|
||||
"fontSize",
|
||||
"fontWeight",
|
||||
"letterSpacing",
|
||||
"lineHeight",
|
||||
"padding",
|
||||
"textDecoration",
|
||||
"textIndent",
|
||||
"textTransform",
|
||||
"whiteSpace",
|
||||
"wordSpacing",
|
||||
"wordWrap",
|
||||
] as const) {
|
||||
mirrored.style[property] = textareaStyles[property];
|
||||
}
|
||||
|
||||
mirrored.style.visibility = "hidden";
|
||||
container.prepend(mirrored);
|
||||
|
||||
const caretRect = caret.getBoundingClientRect();
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
|
||||
container.removeChild(mirrored);
|
||||
|
||||
return {
|
||||
relative: {
|
||||
top: caretRect.top - containerRect.top - textarea.scrollTop,
|
||||
left: caretRect.left - containerRect.left - textarea.scrollLeft,
|
||||
},
|
||||
absolute: {
|
||||
top: caretRect.top - textarea.scrollTop,
|
||||
left: caretRect.left - textarea.scrollLeft,
|
||||
},
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleCommandSelect = (command: string) => {
|
||||
setMessage(command);
|
||||
textareaRef.current?.focus();
|
||||
};
|
||||
|
||||
const handleFileSelect = (filePath: string) => {
|
||||
setMessage(filePath);
|
||||
textareaRef.current?.focus();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={containerClassName}>
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 p-3 text-sm text-destructive bg-destructive/10 border border-destructive/20 rounded-md mb-4">
|
||||
<AlertCircleIcon className="w-4 h-4" />
|
||||
<span>Failed to send message. Please try again.</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="relative" ref={containerRef}>
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
value={message}
|
||||
onChange={(e) => {
|
||||
if (
|
||||
e.target.value.endsWith("@") ||
|
||||
e.target.value.endsWith("/")
|
||||
) {
|
||||
const position = getCursorPosition();
|
||||
if (position) {
|
||||
setCursorPosition(position);
|
||||
}
|
||||
}
|
||||
|
||||
setMessage(e.target.value);
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
className={`${minHeight} resize-none`}
|
||||
disabled={isPending || disabled}
|
||||
maxLength={4000}
|
||||
aria-label="Message input with completion support"
|
||||
aria-describedby={helpId}
|
||||
aria-expanded={message.startsWith("/") || message.includes("@")}
|
||||
aria-haspopup="listbox"
|
||||
role="combobox"
|
||||
aria-autocomplete="list"
|
||||
/>
|
||||
<InlineCompletion
|
||||
projectId={projectId}
|
||||
message={message}
|
||||
commandCompletionRef={commandCompletionRef}
|
||||
fileCompletionRef={fileCompletionRef}
|
||||
handleCommandSelect={handleCommandSelect}
|
||||
handleFileSelect={handleFileSelect}
|
||||
cursorPosition={cursorPosition}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-muted-foreground" id={helpId}>
|
||||
{message.length}/4000 characters " • Use arrow keys to navigate
|
||||
completions"
|
||||
</span>
|
||||
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={!message.trim() || isPending || disabled}
|
||||
size={buttonSize}
|
||||
className="gap-2"
|
||||
>
|
||||
{isPending ? (
|
||||
<>
|
||||
<LoaderIcon className="w-4 h-4 animate-spin" />
|
||||
Sending... This may take a while.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<SendIcon className="w-4 h-4" />
|
||||
{buttonText}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -93,19 +93,19 @@ export const CommandCompletion = forwardRef<
|
||||
);
|
||||
|
||||
// スクロール処理
|
||||
const scrollToSelected = useCallback(() => {
|
||||
if (selectedIndex >= 0 && listRef.current) {
|
||||
const selectedElement = listRef.current.children[
|
||||
selectedIndex + 1
|
||||
] as HTMLElement; // +1 for header
|
||||
if (selectedElement) {
|
||||
selectedElement.scrollIntoView({
|
||||
const scrollToSelected = useCallback((index: number) => {
|
||||
if (index >= 0 && listRef.current) {
|
||||
// ボタン要素を直接検索
|
||||
const buttons = listRef.current.querySelectorAll('button[role="option"]');
|
||||
const selectedButton = buttons[index] as HTMLElement;
|
||||
if (selectedButton) {
|
||||
selectedButton.scrollIntoView({
|
||||
block: "nearest",
|
||||
behavior: "smooth",
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [selectedIndex]);
|
||||
}, []);
|
||||
|
||||
// メモ化されたキーボードナビゲーション処理
|
||||
const handleKeyboardNavigation = useCallback(
|
||||
@@ -117,8 +117,8 @@ export const CommandCompletion = forwardRef<
|
||||
e.preventDefault();
|
||||
setSelectedIndex((prev) => {
|
||||
const newIndex = prev < filteredCommands.length - 1 ? prev + 1 : 0;
|
||||
// スクロールを次のタイクで実行
|
||||
setTimeout(scrollToSelected, 0);
|
||||
// スクロールを次のフレームで実行
|
||||
requestAnimationFrame(() => scrollToSelected(newIndex));
|
||||
return newIndex;
|
||||
});
|
||||
return true;
|
||||
@@ -126,8 +126,8 @@ export const CommandCompletion = forwardRef<
|
||||
e.preventDefault();
|
||||
setSelectedIndex((prev) => {
|
||||
const newIndex = prev > 0 ? prev - 1 : filteredCommands.length - 1;
|
||||
// スクロールを次のタイクで実行
|
||||
setTimeout(scrollToSelected, 0);
|
||||
// スクロールを次のフレームで実行
|
||||
requestAnimationFrame(() => scrollToSelected(newIndex));
|
||||
return newIndex;
|
||||
});
|
||||
return true;
|
||||
@@ -214,7 +214,7 @@ export const CommandCompletion = forwardRef<
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"w-full justify-start text-left font-mono text-sm h-8 px-2",
|
||||
"w-full justify-start text-left font-mono text-sm h-8 px-2 min-w-0",
|
||||
index === selectedIndex &&
|
||||
"bg-accent text-accent-foreground",
|
||||
)}
|
||||
@@ -223,11 +223,16 @@ export const CommandCompletion = forwardRef<
|
||||
role="option"
|
||||
aria-selected={index === selectedIndex}
|
||||
aria-label={`Command: /${command}`}
|
||||
title={`/${command}`}
|
||||
>
|
||||
<span className="text-muted-foreground mr-1">/</span>
|
||||
<span className="font-medium">{command}</span>
|
||||
<span className="text-muted-foreground mr-1 flex-shrink-0">
|
||||
/
|
||||
</span>
|
||||
<span className="font-medium truncate min-w-0">
|
||||
{command}
|
||||
</span>
|
||||
{index === selectedIndex && (
|
||||
<CheckIcon className="w-3 h-3 ml-auto text-primary" />
|
||||
<CheckIcon className="w-3 h-3 ml-auto text-primary flex-shrink-0" />
|
||||
)}
|
||||
</Button>
|
||||
))}
|
||||
@@ -0,0 +1,326 @@
|
||||
import { CheckIcon, FileIcon, FolderIcon } from "lucide-react";
|
||||
import type React from "react";
|
||||
import {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Button } from "../../../../../components/ui/button";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
} from "../../../../../components/ui/collapsible";
|
||||
import {
|
||||
type FileCompletionEntry,
|
||||
useFileCompletion,
|
||||
} from "../../../../../hooks/useFileCompletion";
|
||||
import { cn } from "../../../../../lib/utils";
|
||||
|
||||
type FileCompletionProps = {
|
||||
projectId: string;
|
||||
inputValue: string;
|
||||
onFileSelect: (filePath: string) => void;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export type FileCompletionRef = {
|
||||
handleKeyDown: (e: React.KeyboardEvent) => boolean;
|
||||
};
|
||||
|
||||
// Parse the @ completion from input value
|
||||
const parseFileCompletionFromInput = (input: string) => {
|
||||
// Find the last @ symbol
|
||||
const lastAtIndex = input.lastIndexOf("@");
|
||||
if (lastAtIndex === -1) {
|
||||
return { shouldShow: false, searchPath: "", beforeAt: "", afterAt: "" };
|
||||
}
|
||||
|
||||
// Get the text before and after @
|
||||
const beforeAt = input.slice(0, lastAtIndex);
|
||||
const afterAt = input.slice(lastAtIndex + 1);
|
||||
|
||||
// Check if we're in the middle of a word after @ (no space after the path)
|
||||
const parts = afterAt.split(/\s/);
|
||||
const searchPath = parts[0] || "";
|
||||
|
||||
// Don't show completion if there's a space after the path (user has finished typing the path)
|
||||
// This includes cases like "@hoge " where parts = ["hoge", ""]
|
||||
const hasSpaceAfterPath = parts.length > 1;
|
||||
|
||||
return {
|
||||
shouldShow: !hasSpaceAfterPath,
|
||||
searchPath,
|
||||
beforeAt,
|
||||
afterAt,
|
||||
};
|
||||
};
|
||||
|
||||
export const FileCompletion = forwardRef<
|
||||
FileCompletionRef,
|
||||
FileCompletionProps
|
||||
>(({ projectId, inputValue, onFileSelect, className }, ref) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [selectedIndex, setSelectedIndex] = useState(-1);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Parse the input to extract the path being completed
|
||||
const { shouldShow, searchPath, beforeAt, afterAt } = useMemo(
|
||||
() => parseFileCompletionFromInput(inputValue),
|
||||
[inputValue],
|
||||
);
|
||||
|
||||
// Determine the base path and filter term
|
||||
const { basePath, filterTerm } = useMemo(() => {
|
||||
if (!searchPath) {
|
||||
return { basePath: "/", filterTerm: "" };
|
||||
}
|
||||
|
||||
const lastSlashIndex = searchPath.lastIndexOf("/");
|
||||
if (lastSlashIndex === -1) {
|
||||
return { basePath: "/", filterTerm: searchPath };
|
||||
}
|
||||
|
||||
const path = searchPath.slice(0, lastSlashIndex + 1);
|
||||
const term = searchPath.slice(lastSlashIndex + 1);
|
||||
return {
|
||||
basePath: path === "/" ? "/" : path,
|
||||
filterTerm: term,
|
||||
};
|
||||
}, [searchPath]);
|
||||
|
||||
// Fetch file completion data
|
||||
const { data: completionData, isLoading } = useFileCompletion(
|
||||
projectId,
|
||||
basePath,
|
||||
shouldShow,
|
||||
);
|
||||
|
||||
// Filter entries based on the current filter term
|
||||
const filteredEntries = useMemo(() => {
|
||||
if (!completionData?.entries) return [];
|
||||
|
||||
if (!filterTerm) {
|
||||
return completionData.entries;
|
||||
}
|
||||
|
||||
return completionData.entries.filter((entry) =>
|
||||
entry.name.toLowerCase().includes(filterTerm.toLowerCase()),
|
||||
);
|
||||
}, [completionData?.entries, filterTerm]);
|
||||
|
||||
// Determine if completion should be shown
|
||||
const shouldBeOpen = shouldShow && !isLoading && filteredEntries.length > 0;
|
||||
|
||||
// Update open state when it should change
|
||||
if (isOpen !== shouldBeOpen) {
|
||||
setIsOpen(shouldBeOpen);
|
||||
setSelectedIndex(-1);
|
||||
}
|
||||
|
||||
// Handle file/directory selection with different behaviors for different triggers
|
||||
const handleEntrySelect = useCallback(
|
||||
(entry: FileCompletionEntry, forceClose = false) => {
|
||||
const fullPath = entry.path;
|
||||
|
||||
// For directories, add a trailing slash to continue completion (unless forced to close)
|
||||
// For files or when forced to close, add a space to end completion
|
||||
|
||||
// Reconstruct the message with the selected path
|
||||
const remainingText = afterAt.split(/\s/).slice(1).join(" ");
|
||||
const newMessage =
|
||||
`${beforeAt}@${fullPath}${remainingText}`.trim() +
|
||||
(entry.type === "directory" && !forceClose ? "/" : " ");
|
||||
|
||||
onFileSelect(newMessage);
|
||||
|
||||
// Close completion if it's a file, or if forced to close
|
||||
if (entry.type === "file" || forceClose) {
|
||||
setIsOpen(false);
|
||||
setSelectedIndex(-1);
|
||||
}
|
||||
},
|
||||
[beforeAt, afterAt, onFileSelect],
|
||||
);
|
||||
|
||||
// Scroll to selected entry
|
||||
const scrollToSelected = useCallback((index: number) => {
|
||||
if (index >= 0 && listRef.current) {
|
||||
// ボタン要素を直接検索
|
||||
const buttons = listRef.current.querySelectorAll('button[role="option"]');
|
||||
const selectedButton = buttons[index] as HTMLElement;
|
||||
if (selectedButton) {
|
||||
selectedButton.scrollIntoView({
|
||||
block: "nearest",
|
||||
behavior: "smooth",
|
||||
});
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Keyboard navigation
|
||||
const handleKeyboardNavigation = useCallback(
|
||||
(e: React.KeyboardEvent): boolean => {
|
||||
if (!isOpen || filteredEntries.length === 0) return false;
|
||||
|
||||
switch (e.key) {
|
||||
case "ArrowDown":
|
||||
e.preventDefault();
|
||||
setSelectedIndex((prev) => {
|
||||
const newIndex = prev < filteredEntries.length - 1 ? prev + 1 : 0;
|
||||
requestAnimationFrame(() => scrollToSelected(newIndex));
|
||||
return newIndex;
|
||||
});
|
||||
return true;
|
||||
case "ArrowUp":
|
||||
e.preventDefault();
|
||||
setSelectedIndex((prev) => {
|
||||
const newIndex = prev > 0 ? prev - 1 : filteredEntries.length - 1;
|
||||
requestAnimationFrame(() => scrollToSelected(newIndex));
|
||||
return newIndex;
|
||||
});
|
||||
return true;
|
||||
case "Enter":
|
||||
if (selectedIndex >= 0 && selectedIndex < filteredEntries.length) {
|
||||
e.preventDefault();
|
||||
const selectedEntry = filteredEntries[selectedIndex];
|
||||
if (selectedEntry) {
|
||||
// Enter always closes completion (even for directories)
|
||||
handleEntrySelect(selectedEntry, true);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
case "Tab":
|
||||
if (selectedIndex >= 0 && selectedIndex < filteredEntries.length) {
|
||||
e.preventDefault();
|
||||
const selectedEntry = filteredEntries[selectedIndex];
|
||||
if (selectedEntry) {
|
||||
// Tab: continue completion for directories, close for files
|
||||
handleEntrySelect(selectedEntry, selectedEntry.type === "file");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
case "Escape":
|
||||
e.preventDefault();
|
||||
setIsOpen(false);
|
||||
setSelectedIndex(-1);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
[
|
||||
isOpen,
|
||||
filteredEntries.length,
|
||||
selectedIndex,
|
||||
handleEntrySelect,
|
||||
scrollToSelected,
|
||||
filteredEntries,
|
||||
],
|
||||
);
|
||||
|
||||
// Handle clicks outside the component
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (
|
||||
containerRef.current &&
|
||||
!containerRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setIsOpen(false);
|
||||
setSelectedIndex(-1);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, []);
|
||||
|
||||
// Expose keyboard handler to parent
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
handleKeyDown: handleKeyboardNavigation,
|
||||
}),
|
||||
[handleKeyboardNavigation],
|
||||
);
|
||||
|
||||
if (!shouldShow || isLoading || filteredEntries.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={cn("relative", className)}>
|
||||
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
|
||||
<CollapsibleContent>
|
||||
<div
|
||||
ref={listRef}
|
||||
className="absolute z-50 w-full mt-1 bg-popover border border-border rounded-md shadow-lg max-h-48 overflow-y-auto"
|
||||
role="listbox"
|
||||
aria-label="Available files and directories"
|
||||
>
|
||||
{filteredEntries.length > 0 && (
|
||||
<div className="p-1">
|
||||
<div
|
||||
className="px-2 py-1.5 text-xs font-medium text-muted-foreground border-b border-border mb-1 flex items-center gap-2"
|
||||
role="presentation"
|
||||
>
|
||||
<FileIcon className="w-3 h-3" />
|
||||
Files & Directories ({filteredEntries.length})
|
||||
{basePath !== "/" && (
|
||||
<span className="text-xs font-mono text-muted-foreground/70">
|
||||
in {basePath}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{filteredEntries.map((entry, index) => (
|
||||
<Button
|
||||
key={entry.path}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"w-full justify-start text-left font-mono text-sm h-8 px-2 min-w-0",
|
||||
index === selectedIndex &&
|
||||
"bg-accent text-accent-foreground",
|
||||
)}
|
||||
onClick={() =>
|
||||
handleEntrySelect(entry, entry.type === "file")
|
||||
}
|
||||
onMouseEnter={() => setSelectedIndex(index)}
|
||||
role="option"
|
||||
aria-selected={index === selectedIndex}
|
||||
aria-label={`${entry.type}: ${entry.name}`}
|
||||
title={entry.path}
|
||||
>
|
||||
{entry.type === "directory" ? (
|
||||
<FolderIcon className="w-3 h-3 mr-2 text-blue-500 flex-shrink-0" />
|
||||
) : (
|
||||
<FileIcon className="w-3 h-3 mr-2 text-gray-500 flex-shrink-0" />
|
||||
)}
|
||||
<span className="font-medium truncate min-w-0">
|
||||
{entry.name}
|
||||
</span>
|
||||
{entry.type === "directory" && (
|
||||
<span className="text-muted-foreground ml-1 flex-shrink-0">
|
||||
/
|
||||
</span>
|
||||
)}
|
||||
{index === selectedIndex && (
|
||||
<CheckIcon className="w-3 h-3 ml-auto text-primary flex-shrink-0" />
|
||||
)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
FileCompletion.displayName = "FileCompletion";
|
||||
@@ -0,0 +1,131 @@
|
||||
import type { FC, RefObject } from "react";
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
CommandCompletion,
|
||||
type CommandCompletionRef,
|
||||
} from "./CommandCompletion";
|
||||
import { FileCompletion, type FileCompletionRef } from "./FileCompletion";
|
||||
|
||||
interface PositionStyle {
|
||||
top: number;
|
||||
left: number;
|
||||
placement: "above" | "below";
|
||||
}
|
||||
|
||||
const calculateOptimalPosition = (
|
||||
relativeCursorPosition: { top: number; left: number },
|
||||
absoluteCursorPosition: { top: number; left: number },
|
||||
): PositionStyle => {
|
||||
const viewportHeight =
|
||||
typeof window !== "undefined" ? window.innerHeight : 800;
|
||||
const viewportCenter = viewportHeight / 2;
|
||||
|
||||
// Estimated completion height (we'll measure actual height later if needed)
|
||||
const estimatedCompletionHeight = 200;
|
||||
|
||||
// Determine preferred placement based on viewport position
|
||||
const isInUpperHalf = absoluteCursorPosition.top < viewportCenter;
|
||||
|
||||
// Check if there's enough space for preferred placement
|
||||
const spaceBelow = viewportHeight - absoluteCursorPosition.top;
|
||||
const spaceAbove = absoluteCursorPosition.top;
|
||||
|
||||
let placement: "above" | "below";
|
||||
let top: number;
|
||||
|
||||
if (isInUpperHalf && spaceBelow >= estimatedCompletionHeight) {
|
||||
// Cursor in upper half and enough space below - place below
|
||||
placement = "below";
|
||||
top = relativeCursorPosition.top + 16;
|
||||
} else if (!isInUpperHalf && spaceAbove >= estimatedCompletionHeight) {
|
||||
// Cursor in lower half and enough space above - place above
|
||||
placement = "above";
|
||||
top = relativeCursorPosition.top - estimatedCompletionHeight - 8;
|
||||
} else {
|
||||
// Use whichever side has more space
|
||||
if (spaceBelow > spaceAbove) {
|
||||
placement = "below";
|
||||
top = relativeCursorPosition.top + 16;
|
||||
} else {
|
||||
placement = "above";
|
||||
top = relativeCursorPosition.top - estimatedCompletionHeight - 8;
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure left position stays within viewport bounds
|
||||
const estimatedCompletionWidth = 512; // Current w-lg width
|
||||
const viewportWidth =
|
||||
typeof window !== "undefined" ? window.innerWidth : 1200;
|
||||
const maxLeft = viewportWidth - estimatedCompletionWidth - 16;
|
||||
const adjustedLeft = Math.max(
|
||||
16,
|
||||
Math.min(relativeCursorPosition.left - 16, maxLeft),
|
||||
);
|
||||
|
||||
return {
|
||||
top,
|
||||
left: adjustedLeft,
|
||||
placement,
|
||||
};
|
||||
};
|
||||
|
||||
export const InlineCompletion: FC<{
|
||||
projectId: string;
|
||||
message: string;
|
||||
commandCompletionRef: RefObject<CommandCompletionRef | null>;
|
||||
fileCompletionRef: RefObject<FileCompletionRef | null>;
|
||||
handleCommandSelect: (command: string) => void;
|
||||
handleFileSelect: (filePath: string) => void;
|
||||
cursorPosition: {
|
||||
relative: { top: number; left: number };
|
||||
absolute: { top: number; left: number };
|
||||
};
|
||||
}> = ({
|
||||
projectId,
|
||||
message,
|
||||
commandCompletionRef,
|
||||
fileCompletionRef,
|
||||
handleCommandSelect,
|
||||
handleFileSelect,
|
||||
cursorPosition,
|
||||
}) => {
|
||||
const position = useMemo(() => {
|
||||
return calculateOptimalPosition(
|
||||
cursorPosition.relative,
|
||||
cursorPosition.absolute,
|
||||
);
|
||||
}, [cursorPosition]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute w-full max-w-sm sm:max-w-md lg:max-w-lg xl:max-w-xl"
|
||||
style={{
|
||||
top: position.top,
|
||||
left: position.left,
|
||||
maxWidth:
|
||||
typeof window !== "undefined"
|
||||
? Math.min(512, window.innerWidth * 0.8)
|
||||
: 512,
|
||||
}}
|
||||
>
|
||||
<CommandCompletion
|
||||
ref={commandCompletionRef}
|
||||
projectId={projectId}
|
||||
inputValue={message}
|
||||
onCommandSelect={handleCommandSelect}
|
||||
className={`absolute left-0 right-0 ${
|
||||
position.placement === "above" ? "bottom-full mb-2" : "top-full mt-1"
|
||||
}`}
|
||||
/>
|
||||
<FileCompletion
|
||||
ref={fileCompletionRef}
|
||||
projectId={projectId}
|
||||
inputValue={message}
|
||||
onFileSelect={handleFileSelect}
|
||||
className={`absolute left-0 right-0 ${
|
||||
position.placement === "above" ? "bottom-full mb-2" : "top-full mt-1"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
export type { ChatInputProps } from "./ChatInput";
|
||||
export { ChatInput } from "./ChatInput";
|
||||
export type { CommandCompletionRef } from "./CommandCompletion";
|
||||
export { CommandCompletion } from "./CommandCompletion";
|
||||
export type { FileCompletionRef } from "./FileCompletion";
|
||||
export { FileCompletion } from "./FileCompletion";
|
||||
export { useNewChatMutation, useResumeChatMutation } from "./useChatMutations";
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { honoClient } from "../../../../../lib/api/client";
|
||||
|
||||
export const useNewChatMutation = (
|
||||
projectId: string,
|
||||
onSuccess?: () => void,
|
||||
) => {
|
||||
const router = useRouter();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (options: { message: string }) => {
|
||||
const response = await honoClient.api.projects[":projectId"][
|
||||
"new-session"
|
||||
].$post(
|
||||
{
|
||||
param: { projectId },
|
||||
json: { message: options.message },
|
||||
},
|
||||
{
|
||||
init: {
|
||||
signal: AbortSignal.timeout(20 * 1000),
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(response.statusText);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
onSuccess: async (response) => {
|
||||
onSuccess?.();
|
||||
router.push(
|
||||
`/projects/${projectId}/sessions/${response.sessionId}#message-${response.userMessageId}`,
|
||||
);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useResumeChatMutation = (projectId: string, sessionId: string) => {
|
||||
const router = useRouter();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (options: { message: string }) => {
|
||||
const response = await honoClient.api.projects[":projectId"].sessions[
|
||||
":sessionId"
|
||||
].resume.$post(
|
||||
{
|
||||
param: { projectId, sessionId },
|
||||
json: { resumeMessage: options.message },
|
||||
},
|
||||
{
|
||||
init: {
|
||||
signal: AbortSignal.timeout(20 * 1000),
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(response.statusText);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
onSuccess: async (response) => {
|
||||
if (sessionId !== response.sessionId) {
|
||||
router.push(
|
||||
`/projects/${projectId}/sessions/${response.sessionId}#message-${response.userMessageId}`,
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -1,136 +1,36 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { AlertCircleIcon, LoaderIcon, SendIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { type FC, useId, useRef, useState } from "react";
|
||||
import { Button } from "../../../../../components/ui/button";
|
||||
import { Textarea } from "../../../../../components/ui/textarea";
|
||||
import { honoClient } from "../../../../../lib/api/client";
|
||||
import {
|
||||
CommandCompletion,
|
||||
type CommandCompletionRef,
|
||||
} from "./CommandCompletion";
|
||||
import type { FC } from "react";
|
||||
import { useConfig } from "../../../../hooks/useConfig";
|
||||
import { ChatInput, useNewChatMutation } from "../chatForm";
|
||||
|
||||
export const NewChat: FC<{
|
||||
projectId: string;
|
||||
onSuccess?: () => void;
|
||||
}> = ({ projectId, onSuccess }) => {
|
||||
const router = useRouter();
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const startNewChat = useNewChatMutation(projectId, onSuccess);
|
||||
const { config } = useConfig();
|
||||
|
||||
const startNewChat = useMutation({
|
||||
mutationFn: async (options: { message: string }) => {
|
||||
const response = await honoClient.api.projects[":projectId"][
|
||||
"new-session"
|
||||
].$post({
|
||||
param: { projectId },
|
||||
json: { message: options.message },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(response.statusText);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
onSuccess: async (response) => {
|
||||
setMessage("");
|
||||
onSuccess?.();
|
||||
|
||||
router.push(
|
||||
`/projects/${projectId}/sessions/${response.sessionId}#message-${response.userMessageId}`,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const [message, setMessage] = useState("");
|
||||
const completionRef = useRef<CommandCompletionRef>(null);
|
||||
const helpId = useId();
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!message.trim()) return;
|
||||
startNewChat.mutate({ message: message.trim() });
|
||||
const handleSubmit = async (message: string) => {
|
||||
await startNewChat.mutateAsync({ message });
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
// まずコマンド補完のキーボードイベントを処理
|
||||
if (completionRef.current?.handleKeyDown(e)) {
|
||||
return;
|
||||
const getPlaceholder = () => {
|
||||
const isEnterSend = config?.enterKeyBehavior === "enter-send";
|
||||
if (isEnterSend) {
|
||||
return "Type your message here... (Start with / for commands, @ for files, Enter to send)";
|
||||
}
|
||||
|
||||
// 通常のキーボードイベント処理
|
||||
if (e.key === "Enter" && e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
const handleCommandSelect = (command: string) => {
|
||||
setMessage(command);
|
||||
textareaRef.current?.focus();
|
||||
return "Type your message here... (Start with / for commands, @ for files, Shift+Enter to send)";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{startNewChat.error && (
|
||||
<div className="flex items-center gap-2 p-3 text-sm text-destructive bg-destructive/10 border border-destructive/20 rounded-md">
|
||||
<AlertCircleIcon className="w-4 h-4" />
|
||||
<span>Failed to start new chat. Please try again.</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="relative">
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Type your message here... (Start with / for commands, Shift+Enter to send)"
|
||||
className="min-h-[100px] resize-none"
|
||||
disabled={startNewChat.isPending}
|
||||
maxLength={4000}
|
||||
aria-label="Message input with command completion"
|
||||
aria-describedby={helpId}
|
||||
aria-expanded={message.startsWith("/")}
|
||||
aria-haspopup="listbox"
|
||||
role="combobox"
|
||||
aria-autocomplete="list"
|
||||
/>
|
||||
<CommandCompletion
|
||||
ref={completionRef}
|
||||
projectId={projectId}
|
||||
inputValue={message}
|
||||
onCommandSelect={handleCommandSelect}
|
||||
className="absolute top-full left-0 right-0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-muted-foreground" id={helpId}>
|
||||
{message.length}/4000 characters • Use arrow keys to navigate
|
||||
commands
|
||||
</span>
|
||||
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={!message.trim() || startNewChat.isPending}
|
||||
size="lg"
|
||||
className="gap-2"
|
||||
>
|
||||
{startNewChat.isPending ? (
|
||||
<>
|
||||
<LoaderIcon className="w-4 h-4 animate-spin" />
|
||||
Sending... This may take a while.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<SendIcon className="w-4 h-4" />
|
||||
Start Chat
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ChatInput
|
||||
projectId={projectId}
|
||||
onSubmit={handleSubmit}
|
||||
isPending={startNewChat.isPending}
|
||||
error={startNewChat.error}
|
||||
placeholder={getPlaceholder()}
|
||||
buttonText="Start Chat"
|
||||
minHeight="min-h-[200px]"
|
||||
containerClassName="space-y-4"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,7 +4,6 @@ import { Button } from "../../../../../components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
@@ -31,15 +30,12 @@ export const NewChatModal: FC<{
|
||||
</Button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogContent className="w-[95vw] md:w-[80vw]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<MessageSquareIcon className="w-5 h-5" />
|
||||
Start New Chat
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Start a new conversation with Claude Code for this project
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<NewChat projectId={projectId} onSuccess={handleSuccess} />
|
||||
</DialogContent>
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
ExternalLinkIcon,
|
||||
GitCompareIcon,
|
||||
LoaderIcon,
|
||||
MenuIcon,
|
||||
PauseIcon,
|
||||
@@ -12,12 +13,15 @@ import Link from "next/link";
|
||||
import type { FC } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTaskNotifications } from "@/hooks/useTaskNotifications";
|
||||
import { Badge } from "../../../../../../components/ui/badge";
|
||||
import { honoClient } from "../../../../../../lib/api/client";
|
||||
import { useProject } from "../../../hooks/useProject";
|
||||
import { firstCommandToTitle } from "../../../services/firstCommandToTitle";
|
||||
import { useAliveTask } from "../hooks/useAliveTask";
|
||||
import { useSession } from "../hooks/useSession";
|
||||
import { ConversationList } from "./conversationList/ConversationList";
|
||||
import { DiffModal } from "./diffModal";
|
||||
import { ResumeChat } from "./resumeChat/ResumeChat";
|
||||
import { SessionSidebar } from "./sessionSidebar/SessionSidebar";
|
||||
|
||||
@@ -47,9 +51,13 @@ export const SessionPageContent: FC<{
|
||||
|
||||
const { isRunningTask, isPausedTask } = useAliveTask(sessionId);
|
||||
|
||||
// Set up task completion notifications
|
||||
useTaskNotifications(isRunningTask);
|
||||
|
||||
const [previousConversationLength, setPreviousConversationLength] =
|
||||
useState(0);
|
||||
const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false);
|
||||
const [isDiffModalOpen, setIsDiffModalOpen] = useState(false);
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 自動スクロール処理
|
||||
@@ -70,7 +78,7 @@ export const SessionPageContent: FC<{
|
||||
}, [conversations, isRunningTask, isPausedTask, previousConversationLength]);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen max-h-screen">
|
||||
<div className="flex h-screen max-h-screen overflow-hidden">
|
||||
<SessionSidebar
|
||||
currentSessionId={sessionId}
|
||||
projectId={projectId}
|
||||
@@ -78,57 +86,55 @@ export const SessionPageContent: FC<{
|
||||
onMobileOpenChange={setIsMobileSidebarOpen}
|
||||
/>
|
||||
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
<header className="px-2 sm:px-3 py-3 sticky top-0 z-10 bg-background w-full flex-shrink-0">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="md:hidden"
|
||||
onClick={() => setIsMobileSidebarOpen(true)}
|
||||
>
|
||||
<MenuIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
<Button asChild variant="ghost">
|
||||
<Link
|
||||
href={`/projects/${projectId}`}
|
||||
className="flex items-center gap-2"
|
||||
<div className="flex-1 flex flex-col min-h-0 min-w-0">
|
||||
<header className="px-2 sm:px-3 py-2 sm:py-3 sticky top-0 z-10 bg-background w-full flex-shrink-0 min-w-0">
|
||||
<div className="space-y-2 sm:space-y-3">
|
||||
<div className="flex items-center gap-2 sm:gap-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="md:hidden flex-shrink-0"
|
||||
onClick={() => setIsMobileSidebarOpen(true)}
|
||||
>
|
||||
<ArrowLeftIcon className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">Back to Session List</span>
|
||||
<span className="sm:hidden">Back</span>
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-xl sm:text-2xl md:text-3xl font-bold break-words overflow-ellipsis line-clamp-1 px-2 sm:px-5">
|
||||
<MenuIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
<h1 className="text-lg sm:text-2xl md:text-3xl font-bold break-all overflow-ellipsis line-clamp-1 px-1 sm:px-5 min-w-0">
|
||||
{session.meta.firstCommand !== null
|
||||
? firstCommandToTitle(session.meta.firstCommand)
|
||||
: sessionId}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="px-2 sm:px-5 space-y-1">
|
||||
<div className="px-1 sm:px-5 flex flex-wrap items-center gap-1 sm:gap-2">
|
||||
{project?.project.claudeProjectPath && (
|
||||
<p className="text-sm text-muted-foreground font-mono break-all">
|
||||
Project:{" "}
|
||||
{project.project.meta.projectPath ??
|
||||
project.project.claudeProjectPath}
|
||||
</p>
|
||||
<Link
|
||||
href={`/projects/${projectId}`}
|
||||
target="_blank"
|
||||
className="transition-all duration-200"
|
||||
>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="h-6 sm:h-8 text-xs sm:text-sm flex items-center hover:bg-blue-50/60 hover:border-blue-300/60 hover:shadow-sm transition-all duration-200 cursor-pointer"
|
||||
>
|
||||
<ExternalLinkIcon className="w-3 h-3 sm:w-4 sm:h-4 mr-1" />
|
||||
{project.project.meta.projectPath ??
|
||||
project.project.claudeProjectPath}
|
||||
</Badge>
|
||||
</Link>
|
||||
)}
|
||||
<p className="text-sm text-muted-foreground font-mono">
|
||||
Session ID: {sessionId}
|
||||
</p>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="h-6 sm:h-8 text-xs sm:text-sm flex items-center"
|
||||
>
|
||||
{sessionId}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{isRunningTask && (
|
||||
<div className="flex items-center gap-2 p-3 bg-primary/10 border border-primary/20 rounded-lg">
|
||||
<LoaderIcon className="w-4 h-4 animate-spin" />
|
||||
<div className="flex items-center gap-1 sm:gap-2 p-1 bg-primary/10 border border-primary/20 rounded-lg mx-1 sm:mx-5">
|
||||
<LoaderIcon className="w-3 h-3 sm:w-4 sm:h-4 animate-spin" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium">
|
||||
<p className="text-xs sm:text-sm font-medium">
|
||||
Conversation is in progress...
|
||||
</p>
|
||||
</div>
|
||||
@@ -139,17 +145,17 @@ export const SessionPageContent: FC<{
|
||||
abortTask.mutate(sessionId);
|
||||
}}
|
||||
>
|
||||
<XIcon className="w-4 h-4" />
|
||||
Abort
|
||||
<XIcon className="w-3 h-3 sm:w-4 sm:h-4" />
|
||||
<span className="hidden sm:inline">Abort</span>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isPausedTask && (
|
||||
<div className="flex items-center gap-2 p-3 bg-primary/10 border border-primary/20 rounded-lg">
|
||||
<PauseIcon className="w-4 h-4" />
|
||||
<div className="flex items-center gap-1 sm:gap-2 p-1 bg-primary/10 border border-primary/20 rounded-lg mx-1 sm:mx-5">
|
||||
<PauseIcon className="w-3 h-3 sm:w-4 sm:h-4" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium">
|
||||
<p className="text-xs sm:text-sm font-medium">
|
||||
Conversation is paused...
|
||||
</p>
|
||||
</div>
|
||||
@@ -160,8 +166,8 @@ export const SessionPageContent: FC<{
|
||||
abortTask.mutate(sessionId);
|
||||
}}
|
||||
>
|
||||
<XIcon className="w-4 h-4" />
|
||||
Abort
|
||||
<XIcon className="w-3 h-3 sm:w-4 sm:h-4" />
|
||||
<span className="hidden sm:inline">Abort</span>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -170,14 +176,31 @@ export const SessionPageContent: FC<{
|
||||
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className="flex-1 overflow-y-auto min-h-0"
|
||||
className="flex-1 overflow-y-auto min-h-0 min-w-0"
|
||||
>
|
||||
<main className="w-full px-4 sm:px-8 md:px-12 lg:px-16 xl:px-20 pb-20 sm:pb-10 relative z-5">
|
||||
<main className="w-full px-4 sm:px-6 md:px-8 lg:px-12 xl:px-16 relative z-5 min-w-0">
|
||||
<ConversationList
|
||||
conversations={conversations}
|
||||
getToolResult={getToolResult}
|
||||
/>
|
||||
|
||||
{isRunningTask && (
|
||||
<div className="flex justify-start items-center py-8">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex gap-1">
|
||||
<div className="w-2 h-2 bg-primary rounded-full animate-bounce"></div>
|
||||
<div className="w-2 h-2 bg-primary rounded-full animate-bounce [animation-delay:0.1s]"></div>
|
||||
<div className="w-2 h-2 bg-primary rounded-full animate-bounce [animation-delay:0.2s]"></div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground font-medium">
|
||||
Claude Code is processing...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ResumeChat
|
||||
projectId={projectId}
|
||||
sessionId={sessionId}
|
||||
@@ -187,6 +210,22 @@ export const SessionPageContent: FC<{
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Fixed Diff Button */}
|
||||
<Button
|
||||
onClick={() => setIsDiffModalOpen(true)}
|
||||
className="fixed bottom-6 right-6 w-14 h-14 rounded-full shadow-lg hover:shadow-xl transition-all duration-200 z-50"
|
||||
size="lg"
|
||||
>
|
||||
<GitCompareIcon className="w-6 h-6" />
|
||||
</Button>
|
||||
|
||||
{/* Diff Modal */}
|
||||
<DiffModal
|
||||
projectId={projectId}
|
||||
isOpen={isDiffModalOpen}
|
||||
onOpenChange={setIsDiffModalOpen}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -37,7 +37,7 @@ const getConversationKey = (conversation: Conversation) => {
|
||||
const SchemaErrorDisplay: FC<{ errorLine: string }> = ({ errorLine }) => {
|
||||
return (
|
||||
<li className="w-full flex justify-start">
|
||||
<div className="w-full max-w-4xl sm:w-[90%] md:w-[85%] px-2">
|
||||
<div className="w-full max-w-3xl lg:max-w-4xl sm:w-[90%] md:w-[85%] px-2">
|
||||
<Collapsible>
|
||||
<CollapsibleTrigger asChild>
|
||||
<div className="flex items-center justify-between cursor-pointer hover:bg-muted/50 rounded p-2 -mx-2 border-l-2 border-red-400">
|
||||
@@ -146,7 +146,9 @@ export const ConversationList: FC<ConversationListProps> = ({
|
||||
}`}
|
||||
key={getConversationKey(conversation)}
|
||||
>
|
||||
<div className="w-full max-w-4xl sm:w-[90%] md:w-[85%]">{elm}</div>
|
||||
<div className="w-full max-w-3xl lg:max-w-4xl sm:w-[90%] md:w-[85%]">
|
||||
{elm}
|
||||
</div>
|
||||
</li>,
|
||||
];
|
||||
})}
|
||||
|
||||
@@ -12,7 +12,7 @@ export const MetaConversationContent: FC<PropsWithChildren> = ({
|
||||
return (
|
||||
<Collapsible>
|
||||
<CollapsibleTrigger asChild>
|
||||
<div className="flex items-center justify-between cursor-pointer hover:bg-muted/50 rounded p-2 -mx-2">
|
||||
<div className="flex items-center justify-between cursor-pointer hover:bg-muted/50 rounded p-2 -mx-2 mb-2">
|
||||
<h4 className="text-xs font-medium text-muted-foreground">
|
||||
Meta Information
|
||||
</h4>
|
||||
|
||||
@@ -12,7 +12,7 @@ export const SummaryConversationContent: FC<PropsWithChildren> = ({
|
||||
return (
|
||||
<Collapsible>
|
||||
<CollapsibleTrigger asChild>
|
||||
<div className="flex items-center justify-between cursor-pointer hover:bg-muted/50 rounded p-2 -mx-2">
|
||||
<div className="flex items-center justify-between cursor-pointer hover:bg-muted/50 rounded p-2 -mx-2 mb-2">
|
||||
<h4 className="text-xs font-medium text-muted-foreground">
|
||||
Summarized
|
||||
</h4>
|
||||
|
||||
@@ -42,7 +42,7 @@ export const UserTextContent: FC<{ text: string; id?: string }> = ({
|
||||
Arguments:
|
||||
</span>
|
||||
<div className="bg-background rounded border p-2 mt-1">
|
||||
<code className="text-xs whitespace-pre-line">
|
||||
<code className="text-xs whitespace-pre-line break-all">
|
||||
{parsed.commandArgs}
|
||||
</code>
|
||||
</div>
|
||||
@@ -54,7 +54,7 @@ export const UserTextContent: FC<{ text: string; id?: string }> = ({
|
||||
Message:
|
||||
</span>
|
||||
<div className="bg-background rounded border p-2 mt-1">
|
||||
<code className="text-xs whitespace-pre-line">
|
||||
<code className="text-xs whitespace-pre-line break-all">
|
||||
{parsed.commandMessage}
|
||||
</code>
|
||||
</div>
|
||||
|
||||
@@ -76,7 +76,7 @@ export const SidechainConversationModal: FC<
|
||||
</div>
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="!w-[1200px] !max-w-none max-h-[80vh] overflow-hidden flex flex-col">
|
||||
<DialogContent className="w-[95vw] md:w-[90vw] max-h-[80vh] overflow-hidden flex flex-col px-2 md:px-8">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{title.length > 100 ? `${title.slice(0, 100)}...` : title}
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
"use client";
|
||||
|
||||
import { FileText, GitBranch, Loader2, RefreshCcwIcon } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { useCallback, useEffect, useId, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useGitBranches, useGitCommits, useGitDiff } from "../../hooks/useGit";
|
||||
import { DiffViewer } from "./DiffViewer";
|
||||
import type { DiffModalProps, DiffSummary, GitRef } from "./types";
|
||||
|
||||
interface DiffSummaryProps {
|
||||
summary: DiffSummary;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const DiffSummaryComponent: FC<DiffSummaryProps> = ({ summary, className }) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"bg-gray-50 dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<div className="flex items-center gap-1">
|
||||
<FileText className="h-4 w-4 text-gray-600 dark:text-gray-400" />
|
||||
<span className="font-medium">
|
||||
<span className="hidden sm:inline">
|
||||
{summary.filesChanged} files changed
|
||||
</span>
|
||||
<span className="sm:hidden">{summary.filesChanged} files</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{summary.insertions > 0 && (
|
||||
<span className="text-green-600 dark:text-green-400 font-medium">
|
||||
+{summary.insertions}
|
||||
</span>
|
||||
)}
|
||||
{summary.deletions > 0 && (
|
||||
<span className="text-red-600 dark:text-red-400 font-medium">
|
||||
-{summary.deletions}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface RefSelectorProps {
|
||||
label: string;
|
||||
value: string;
|
||||
onValueChange: (value: GitRef["name"]) => void;
|
||||
refs: GitRef[];
|
||||
}
|
||||
|
||||
const RefSelector: FC<RefSelectorProps> = ({
|
||||
label,
|
||||
value,
|
||||
onValueChange,
|
||||
refs,
|
||||
}) => {
|
||||
const id = useId();
|
||||
const getRefIcon = (type: GitRef["type"]) => {
|
||||
switch (type) {
|
||||
case "branch":
|
||||
return <GitBranch className="h-4 w-4" />;
|
||||
case "commit":
|
||||
return <span className="text-xs">📝</span>;
|
||||
case "working":
|
||||
return <span className="text-xs">🚧</span>;
|
||||
default:
|
||||
return <GitBranch className="h-4 w-4" />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor={id}
|
||||
className="text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
<Select value={value} onValueChange={onValueChange}>
|
||||
<SelectTrigger className="w-full sm:w-80">
|
||||
<SelectValue placeholder={`Select ${label.toLowerCase()}`} />
|
||||
</SelectTrigger>
|
||||
<SelectContent id={id}>
|
||||
{refs.map((ref) => (
|
||||
<SelectItem key={ref.name} value={ref.name}>
|
||||
<div className="flex items-center gap-2">
|
||||
{getRefIcon(ref.type)}
|
||||
<span>{ref.displayName}</span>
|
||||
{ref.sha && (
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 font-mono">
|
||||
{ref.sha.substring(0, 7)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const DiffModal: FC<DiffModalProps> = ({
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
projectId,
|
||||
defaultCompareFrom = "HEAD",
|
||||
defaultCompareTo = "working",
|
||||
}) => {
|
||||
const [compareFrom, setCompareFrom] = useState(defaultCompareFrom);
|
||||
const [compareTo, setCompareTo] = useState(defaultCompareTo);
|
||||
|
||||
// API hooks
|
||||
const { data: branchesData, isLoading: isLoadingBranches } =
|
||||
useGitBranches(projectId);
|
||||
const { data: commitsData, isLoading: isLoadingCommits } =
|
||||
useGitCommits(projectId);
|
||||
const {
|
||||
mutate: getDiff,
|
||||
data: diffData,
|
||||
isPending: isDiffLoading,
|
||||
error: diffError,
|
||||
} = useGitDiff();
|
||||
|
||||
// Transform branches and commits data to GitRef format
|
||||
const gitRefs: GitRef[] =
|
||||
branchesData?.success && branchesData.data
|
||||
? [
|
||||
{
|
||||
name: "working" as const,
|
||||
type: "working" as const,
|
||||
displayName: "Uncommitted changes",
|
||||
},
|
||||
{
|
||||
name: "HEAD" as const,
|
||||
type: "commit" as const,
|
||||
displayName: "HEAD",
|
||||
},
|
||||
...branchesData.data.map((branch) => ({
|
||||
name: `branch:${branch.name}` as const,
|
||||
type: "branch" as const,
|
||||
displayName: branch.name + (branch.current ? " (current)" : ""),
|
||||
sha: branch.commit,
|
||||
})),
|
||||
// Add commits from current branch
|
||||
...(commitsData?.success && commitsData.data
|
||||
? commitsData.data.map((commit) => ({
|
||||
name: `commit:${commit.sha}` as const,
|
||||
type: "commit" as const,
|
||||
displayName: `${commit.message.substring(0, 50)}${commit.message.length > 50 ? "..." : ""}`,
|
||||
sha: commit.sha,
|
||||
}))
|
||||
: []),
|
||||
]
|
||||
: [];
|
||||
|
||||
const loadDiff = useCallback(() => {
|
||||
if (compareFrom && compareTo && compareFrom !== compareTo) {
|
||||
getDiff({
|
||||
projectId,
|
||||
fromRef: compareFrom,
|
||||
toRef: compareTo,
|
||||
});
|
||||
}
|
||||
}, [compareFrom, compareTo, getDiff, projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && compareFrom && compareTo) {
|
||||
loadDiff();
|
||||
}
|
||||
}, [isOpen, compareFrom, compareTo, loadDiff]);
|
||||
|
||||
const handleCompare = () => {
|
||||
loadDiff();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-7xl w-[95vw] h-[90vh] overflow-hidden flex flex-col px-2 md:px-8">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Preview Changes</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-3 sm:items-end">
|
||||
<div className="flex flex-col sm:flex-row gap-3 flex-1">
|
||||
<RefSelector
|
||||
label="Compare from"
|
||||
value={compareFrom}
|
||||
onValueChange={setCompareFrom}
|
||||
refs={gitRefs.filter((ref) => ref.name !== "working")}
|
||||
/>
|
||||
<RefSelector
|
||||
label="Compare to"
|
||||
value={compareTo}
|
||||
onValueChange={setCompareTo}
|
||||
refs={gitRefs}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleCompare}
|
||||
disabled={
|
||||
isDiffLoading ||
|
||||
isLoadingBranches ||
|
||||
isLoadingCommits ||
|
||||
compareFrom === compareTo
|
||||
}
|
||||
className="sm:self-end w-full sm:w-auto"
|
||||
>
|
||||
{isDiffLoading ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Loading...
|
||||
</>
|
||||
) : (
|
||||
<RefreshCcwIcon className="w-4 h-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{diffError && (
|
||||
<div className="bg-red-50 dark:bg-red-900/10 border border-red-200 dark:border-red-800 rounded-lg p-4">
|
||||
<p className="text-red-600 dark:text-red-400 text-sm">
|
||||
{diffError.message}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{diffData?.success && (
|
||||
<>
|
||||
<DiffSummaryComponent
|
||||
summary={{
|
||||
filesChanged: diffData.data.files.length,
|
||||
insertions: diffData.data.summary.totalAdditions,
|
||||
deletions: diffData.data.summary.totalDeletions,
|
||||
files: diffData.data.diffs.map((diff) => ({
|
||||
filename: diff.file.filePath,
|
||||
oldFilename: diff.file.oldPath,
|
||||
isNew: diff.file.status === "added",
|
||||
isDeleted: diff.file.status === "deleted",
|
||||
isRenamed: diff.file.status === "renamed",
|
||||
isBinary: false,
|
||||
hunks: diff.hunks,
|
||||
linesAdded: diff.file.additions,
|
||||
linesDeleted: diff.file.deletions,
|
||||
})),
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="flex-1 overflow-auto space-y-6">
|
||||
{diffData.data.diffs.map((diff) => (
|
||||
<DiffViewer
|
||||
key={diff.file.filePath}
|
||||
fileDiff={{
|
||||
filename: diff.file.filePath,
|
||||
oldFilename: diff.file.oldPath,
|
||||
isNew: diff.file.status === "added",
|
||||
isDeleted: diff.file.status === "deleted",
|
||||
isRenamed: diff.file.status === "renamed",
|
||||
isBinary: false,
|
||||
hunks: diff.hunks,
|
||||
linesAdded: diff.file.additions,
|
||||
linesDeleted: diff.file.deletions,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isDiffLoading && (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="text-center space-y-2">
|
||||
<Loader2 className="w-8 h-8 animate-spin mx-auto" />
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Loading diff...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,254 @@
|
||||
"use client";
|
||||
|
||||
import { ChevronDownIcon, ChevronRightIcon, CopyIcon } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "../../../../../../../components/ui/button";
|
||||
import type { DiffHunk, FileDiff } from "./types";
|
||||
|
||||
interface DiffViewerProps {
|
||||
fileDiff: FileDiff;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface DiffHunkProps {
|
||||
hunk: DiffHunk;
|
||||
}
|
||||
|
||||
const DiffHunkComponent: FC<DiffHunkProps> = ({ hunk }) => {
|
||||
return (
|
||||
<div className="relative flex overflow-x-auto">
|
||||
{/* 行番号列(固定) */}
|
||||
<div className="flex-shrink-0 sticky left-0 z-10 bg-white dark:bg-gray-900">
|
||||
{/* 旧行番号列 */}
|
||||
<div className="float-left w-10 bg-gray-50 dark:bg-gray-800/50 border-r border-gray-200 dark:border-gray-700">
|
||||
{hunk.lines.map((line, index) => (
|
||||
<div
|
||||
key={`old-${line.oldLineNumber}-${index}`}
|
||||
className="px-2 py-1 text-sm text-gray-400 dark:text-gray-600 font-mono text-right h-[28px]"
|
||||
>
|
||||
{line.type !== "added" &&
|
||||
line.type !== "hunk" &&
|
||||
line.oldLineNumber
|
||||
? line.oldLineNumber
|
||||
: " "}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* 新行番号列 */}
|
||||
<div className="float-left w-10 bg-gray-50 dark:bg-gray-800/50 border-r border-gray-200 dark:border-gray-700">
|
||||
{hunk.lines.map((line, index) => (
|
||||
<div
|
||||
key={`new-${line.newLineNumber}-${index}`}
|
||||
className="px-2 py-1 text-sm text-gray-400 dark:text-gray-600 font-mono text-right h-[28px]"
|
||||
>
|
||||
{line.type !== "deleted" &&
|
||||
line.type !== "hunk" &&
|
||||
line.newLineNumber
|
||||
? line.newLineNumber
|
||||
: " "}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* コンテンツ列(スクロール可能) */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{hunk.lines.map((line, index) => (
|
||||
<div
|
||||
key={`content-${line.content}-${line.oldLineNumber}-${line.newLineNumber}-${index}`}
|
||||
className={cn("flex border-l-4", {
|
||||
"bg-green-50 dark:bg-green-950/30 border-green-200 dark:border-green-800/50 border-l-green-400":
|
||||
line.type === "added",
|
||||
"bg-red-50 dark:bg-red-950/30 border-red-200 dark:border-red-800/50 border-l-red-400":
|
||||
line.type === "deleted",
|
||||
"bg-blue-50 dark:bg-blue-950/30 border-blue-200 dark:border-blue-800/50 border-l-blue-400":
|
||||
line.type === "hunk",
|
||||
"bg-white dark:bg-gray-900 border-gray-100 dark:border-gray-800 border-l-transparent":
|
||||
line.type === "unchanged",
|
||||
})}
|
||||
>
|
||||
<div className="flex-1 px-2 py-1">
|
||||
<span className="font-mono text-sm whitespace-pre block">
|
||||
<span
|
||||
className={cn({
|
||||
"text-green-600 dark:text-green-400": line.type === "added",
|
||||
"text-red-600 dark:text-red-400": line.type === "deleted",
|
||||
"text-blue-600 dark:text-blue-400 font-medium":
|
||||
line.type === "hunk",
|
||||
"text-gray-400 dark:text-gray-600":
|
||||
line.type === "unchanged",
|
||||
})}
|
||||
>
|
||||
{line.type === "added"
|
||||
? "+"
|
||||
: line.type === "deleted"
|
||||
? "-"
|
||||
: line.type === "hunk"
|
||||
? ""
|
||||
: " "}
|
||||
</span>
|
||||
{line.content || " "}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface FileHeaderProps {
|
||||
fileDiff: FileDiff;
|
||||
isCollapsed: boolean;
|
||||
onToggleCollapse: () => void;
|
||||
}
|
||||
|
||||
const FileHeader: FC<FileHeaderProps> = ({
|
||||
fileDiff,
|
||||
isCollapsed,
|
||||
onToggleCollapse,
|
||||
}) => {
|
||||
const getFileStatusIcon = () => {
|
||||
if (fileDiff.isNew)
|
||||
return <span className="text-green-600 dark:text-green-400">A</span>;
|
||||
if (fileDiff.isDeleted)
|
||||
return <span className="text-red-600 dark:text-red-400">D</span>;
|
||||
if (fileDiff.isRenamed)
|
||||
return <span className="text-blue-600 dark:text-blue-400">R</span>;
|
||||
return <span className="text-gray-600 dark:text-gray-400">M</span>;
|
||||
};
|
||||
|
||||
const getFileStatusText = () => {
|
||||
if (fileDiff.isNew) return "added";
|
||||
if (fileDiff.isDeleted) return "deleted";
|
||||
if (fileDiff.isRenamed) return `renamed from ${fileDiff.oldFilename ?? ""}`;
|
||||
return "modified";
|
||||
};
|
||||
|
||||
const handleCopyFilename = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
await navigator.clipboard.writeText(fileDiff.filename);
|
||||
toast.success("ファイル名をコピーしました");
|
||||
} catch (err) {
|
||||
console.error("Failed to copy filename:", err);
|
||||
toast.error("ファイル名のコピーに失敗しました");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={onToggleCollapse}
|
||||
className="w-full bg-gray-50 dark:bg-gray-800 px-4 py-4 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors min-h-[4rem]"
|
||||
>
|
||||
<div className="w-full space-y-1">
|
||||
{/* Row 1: icon, status, and stats */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{isCollapsed ? (
|
||||
<ChevronRightIcon className="w-4 h-4 text-gray-500" />
|
||||
) : (
|
||||
<ChevronDownIcon className="w-4 h-4 text-gray-500" />
|
||||
)}
|
||||
<div className="w-6 h-6 rounded-full bg-gray-100 dark:bg-gray-700 flex items-center justify-center text-xs font-mono">
|
||||
{getFileStatusIcon()}
|
||||
</div>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{getFileStatusText()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-xs text-gray-500 dark:text-gray-400">
|
||||
{fileDiff.linesAdded > 0 && (
|
||||
<span className="text-green-600 dark:text-green-400">
|
||||
+{fileDiff.linesAdded}
|
||||
</span>
|
||||
)}
|
||||
{fileDiff.linesDeleted > 0 && (
|
||||
<span className="text-red-600 dark:text-red-400">
|
||||
-{fileDiff.linesDeleted}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 2: filename with copy button */}
|
||||
<div className="w-full flex items-center gap-2">
|
||||
<span className="font-mono text-sm font-medium text-black dark:text-white text-left truncate flex-1 min-w-0">
|
||||
{fileDiff.filename}
|
||||
</span>
|
||||
<Button
|
||||
onClick={handleCopyFilename}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="flex-shrink-0 p-1 h-6 w-6 hover:bg-gray-200 dark:hover:bg-gray-600"
|
||||
>
|
||||
<CopyIcon className="w-3 h-3 text-gray-500 dark:text-gray-400" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{fileDiff.isBinary && (
|
||||
<div className="mt-2 text-xs text-gray-500 dark:text-gray-400 text-left">
|
||||
Binary file (content not shown)
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export const DiffViewer: FC<DiffViewerProps> = ({ fileDiff, className }) => {
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
|
||||
const toggleCollapse = () => {
|
||||
setIsCollapsed(!isCollapsed);
|
||||
};
|
||||
|
||||
if (fileDiff.isBinary) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"border border-gray-200 dark:border-gray-700 rounded-lg",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<FileHeader
|
||||
fileDiff={fileDiff}
|
||||
isCollapsed={isCollapsed}
|
||||
onToggleCollapse={toggleCollapse}
|
||||
/>
|
||||
{!isCollapsed && (
|
||||
<div className="text-center py-8 text-gray-500 dark:text-gray-400 text-sm">
|
||||
Binary file cannot be displayed
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"border border-gray-200 dark:border-gray-700 rounded-lg",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<FileHeader
|
||||
fileDiff={fileDiff}
|
||||
isCollapsed={isCollapsed}
|
||||
onToggleCollapse={toggleCollapse}
|
||||
/>
|
||||
{!isCollapsed && (
|
||||
<div className="border-t border-gray-200 dark:border-gray-700">
|
||||
{fileDiff.hunks.map((hunk, index) => (
|
||||
<DiffHunkComponent
|
||||
key={`${hunk.oldStart}-${hunk.newStart}-${index}`}
|
||||
hunk={hunk}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,76 @@
|
||||
// API response types for Git operations
|
||||
export interface GitBranch {
|
||||
name: string;
|
||||
current: boolean;
|
||||
remote?: string;
|
||||
commit: string;
|
||||
ahead?: number;
|
||||
behind?: number;
|
||||
}
|
||||
|
||||
export interface GitBranchesResponse {
|
||||
success: true;
|
||||
data: GitBranch[];
|
||||
}
|
||||
|
||||
export interface GitFileInfo {
|
||||
filePath: string;
|
||||
status: "added" | "modified" | "deleted" | "renamed" | "copied";
|
||||
additions: number;
|
||||
deletions: number;
|
||||
oldPath?: string;
|
||||
}
|
||||
|
||||
export interface GitDiffLine {
|
||||
type: "added" | "deleted" | "unchanged" | "hunk";
|
||||
oldLineNumber?: number;
|
||||
newLineNumber?: number;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface GitDiffHunk {
|
||||
oldStart: number;
|
||||
oldLines: number;
|
||||
newStart: number;
|
||||
newLines: number;
|
||||
lines: GitDiffLine[];
|
||||
}
|
||||
|
||||
export interface GitFileDiff {
|
||||
file: GitFileInfo;
|
||||
hunks: GitDiffHunk[];
|
||||
}
|
||||
|
||||
export interface GitDiffSummary {
|
||||
totalFiles: number;
|
||||
totalAdditions: number;
|
||||
totalDeletions: number;
|
||||
}
|
||||
|
||||
export interface GitDiffResponse {
|
||||
success: true;
|
||||
data: {
|
||||
files: GitFileInfo[];
|
||||
diffs: GitFileDiff[];
|
||||
summary: GitDiffSummary;
|
||||
};
|
||||
}
|
||||
|
||||
export interface GitErrorResponse {
|
||||
success: false;
|
||||
error: {
|
||||
code:
|
||||
| "NOT_A_REPOSITORY"
|
||||
| "BRANCH_NOT_FOUND"
|
||||
| "COMMAND_FAILED"
|
||||
| "PARSE_ERROR";
|
||||
message: string;
|
||||
command?: string;
|
||||
stderr?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type GitApiResponse =
|
||||
| GitBranchesResponse
|
||||
| GitDiffResponse
|
||||
| GitErrorResponse;
|
||||
@@ -0,0 +1,10 @@
|
||||
export { DiffModal } from "./DiffModal";
|
||||
export { DiffViewer } from "./DiffViewer";
|
||||
export type {
|
||||
DiffHunk,
|
||||
DiffLine,
|
||||
DiffModalProps,
|
||||
DiffSummary,
|
||||
FileDiff,
|
||||
GitRef,
|
||||
} from "./types";
|
||||
@@ -0,0 +1,48 @@
|
||||
export interface DiffLine {
|
||||
type: "added" | "deleted" | "unchanged" | "hunk" | "context";
|
||||
oldLineNumber?: number;
|
||||
newLineNumber?: number;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface DiffHunk {
|
||||
oldStart: number;
|
||||
// oldLines: number;
|
||||
newStart: number;
|
||||
// newLines: number;
|
||||
lines: DiffLine[];
|
||||
}
|
||||
|
||||
export interface FileDiff {
|
||||
filename: string;
|
||||
oldFilename?: string;
|
||||
isNew: boolean;
|
||||
isDeleted: boolean;
|
||||
isRenamed: boolean;
|
||||
isBinary: boolean;
|
||||
hunks: DiffHunk[];
|
||||
linesAdded: number;
|
||||
linesDeleted: number;
|
||||
}
|
||||
|
||||
export interface GitRef {
|
||||
name: `branch:${string}` | `commit:${string}` | `HEAD` | "working";
|
||||
type: "branch" | "commit" | "head" | "working";
|
||||
sha?: string;
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
export interface DiffSummary {
|
||||
filesChanged: number;
|
||||
insertions: number;
|
||||
deletions: number;
|
||||
files: FileDiff[];
|
||||
}
|
||||
|
||||
export interface DiffModalProps {
|
||||
projectId: string;
|
||||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
defaultCompareFrom?: string;
|
||||
defaultCompareTo?: string;
|
||||
}
|
||||
@@ -1,15 +1,9 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { AlertCircleIcon, LoaderIcon, SendIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { type FC, useId, useRef, useState } from "react";
|
||||
|
||||
import { Button } from "../../../../../../../components/ui/button";
|
||||
import { Textarea } from "../../../../../../../components/ui/textarea";
|
||||
import { honoClient } from "../../../../../../../lib/api/client";
|
||||
import type { FC } from "react";
|
||||
import { useConfig } from "../../../../../../hooks/useConfig";
|
||||
import {
|
||||
CommandCompletion,
|
||||
type CommandCompletionRef,
|
||||
} from "../../../../components/newChat/CommandCompletion";
|
||||
ChatInput,
|
||||
useResumeChatMutation,
|
||||
} from "../../../../components/chatForm";
|
||||
|
||||
export const ResumeChat: FC<{
|
||||
projectId: string;
|
||||
@@ -17,128 +11,41 @@ export const ResumeChat: FC<{
|
||||
isPausedTask: boolean;
|
||||
isRunningTask: boolean;
|
||||
}> = ({ projectId, sessionId, isPausedTask, isRunningTask }) => {
|
||||
const router = useRouter();
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const resumeChat = useResumeChatMutation(projectId, sessionId);
|
||||
const { config } = useConfig();
|
||||
|
||||
const resumeChat = useMutation({
|
||||
mutationFn: async (options: { message: string }) => {
|
||||
const response = await honoClient.api.projects[":projectId"].sessions[
|
||||
":sessionId"
|
||||
].resume.$post({
|
||||
param: { projectId, sessionId },
|
||||
json: { resumeMessage: options.message },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(response.statusText);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
onSuccess: async (response) => {
|
||||
if (sessionId !== response.sessionId) {
|
||||
router.push(
|
||||
`/projects/${projectId}/sessions/${response.sessionId}#message-${response.userMessageId}`,
|
||||
);
|
||||
}
|
||||
|
||||
setMessage("");
|
||||
},
|
||||
});
|
||||
|
||||
const [message, setMessage] = useState("");
|
||||
const completionRef = useRef<CommandCompletionRef>(null);
|
||||
const helpId = useId();
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!message.trim()) return;
|
||||
resumeChat.mutate({ message: message.trim() });
|
||||
const handleSubmit = async (message: string) => {
|
||||
await resumeChat.mutateAsync({ message });
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
// まずコマンド補完のキーボードイベントを処理
|
||||
if (completionRef.current?.handleKeyDown(e)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 通常のキーボードイベント処理
|
||||
if (e.key === "Enter" && e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
const getButtonText = () => {
|
||||
if (isPausedTask || isRunningTask) {
|
||||
return "Send";
|
||||
}
|
||||
return "Resume";
|
||||
};
|
||||
|
||||
const handleCommandSelect = (command: string) => {
|
||||
setMessage(command);
|
||||
textareaRef.current?.focus();
|
||||
const getPlaceholder = () => {
|
||||
const isEnterSend = config?.enterKeyBehavior === "enter-send";
|
||||
if (isEnterSend) {
|
||||
return "Type your message... (Start with / for commands, Enter to send)";
|
||||
}
|
||||
return "Type your message... (Start with / for commands, Shift+Enter to send)";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border-t border-border/50 bg-muted/20 p-4 mt-6">
|
||||
{resumeChat.error && (
|
||||
<div className="flex items-center gap-2 p-3 mb-3 text-sm text-destructive bg-destructive/10 border border-destructive/20 rounded-md">
|
||||
<AlertCircleIcon className="w-4 h-4" />
|
||||
<span>Failed to resume chat. Please try again.</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="relative">
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Type your message... (Start with / for commands, Shift+Enter to send)"
|
||||
className="min-h-[60px] resize-none"
|
||||
disabled={resumeChat.isPending}
|
||||
maxLength={4000}
|
||||
aria-label="Message input with command completion"
|
||||
aria-describedby={helpId}
|
||||
aria-expanded={message.startsWith("/")}
|
||||
aria-haspopup="listbox"
|
||||
role="combobox"
|
||||
aria-autocomplete="list"
|
||||
/>
|
||||
<CommandCompletion
|
||||
ref={completionRef}
|
||||
projectId={projectId}
|
||||
inputValue={message}
|
||||
onCommandSelect={handleCommandSelect}
|
||||
className="absolute top-full left-0 right-0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-muted-foreground" id={helpId}>
|
||||
{message.length}/4000
|
||||
</span>
|
||||
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={!message.trim() || resumeChat.isPending}
|
||||
size="default"
|
||||
className="gap-2"
|
||||
>
|
||||
{resumeChat.isPending ? (
|
||||
<>
|
||||
<LoaderIcon className="w-4 h-4 animate-spin" />
|
||||
Sending... This may take a while.
|
||||
</>
|
||||
) : isPausedTask || isRunningTask ? (
|
||||
<>
|
||||
<SendIcon className="w-4 h-4" />
|
||||
Send
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<SendIcon className="w-4 h-4" />
|
||||
Resume
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<ChatInput
|
||||
projectId={projectId}
|
||||
onSubmit={handleSubmit}
|
||||
isPending={resumeChat.isPending}
|
||||
error={resumeChat.error}
|
||||
placeholder={getPlaceholder()}
|
||||
buttonText={getButtonText()}
|
||||
minHeight="min-h-[100px]"
|
||||
containerClassName="space-y-2"
|
||||
buttonSize="default"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -79,7 +79,7 @@ export const MobileSidebar: FC<MobileSidebarProps> = ({
|
||||
case "mcp":
|
||||
return <McpTab />;
|
||||
case "settings":
|
||||
return <SettingsTab />;
|
||||
return <SettingsTab openingProjectId={projectId} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { MessageSquareIcon, PlugIcon, SettingsIcon } from "lucide-react";
|
||||
import {
|
||||
MessageSquareIcon,
|
||||
PlugIcon,
|
||||
SettingsIcon,
|
||||
Undo2Icon,
|
||||
} from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { type FC, useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useProject } from "../../../../hooks/useProject";
|
||||
@@ -22,6 +28,7 @@ export const SessionSidebar: FC<{
|
||||
isMobileOpen = false,
|
||||
onMobileOpenChange,
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const {
|
||||
data: { sessions },
|
||||
} = useProject(projectId);
|
||||
@@ -54,7 +61,7 @@ export const SessionSidebar: FC<{
|
||||
case "mcp":
|
||||
return <McpTab />;
|
||||
case "settings":
|
||||
return <SettingsTab />;
|
||||
return <SettingsTab openingProjectId={projectId} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
@@ -70,6 +77,21 @@ export const SessionSidebar: FC<{
|
||||
{/* Vertical Icon Menu - Always Visible */}
|
||||
<div className="w-12 flex flex-col border-r border-sidebar-border bg-sidebar/50">
|
||||
<div className="flex flex-col p-2 space-y-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
router.push(`/projects/${projectId}`);
|
||||
}}
|
||||
className={cn(
|
||||
"w-8 h-8 flex items-center justify-center rounded-md transition-colors",
|
||||
"hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
||||
"text-sidebar-foreground/70",
|
||||
)}
|
||||
title="Back to Project"
|
||||
>
|
||||
<Undo2Icon className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleTabClick("sessions")}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import { NotificationSettings } from "@/components/NotificationSettings";
|
||||
import { SettingsControls } from "@/components/SettingsControls";
|
||||
|
||||
export const SettingsTab: FC = () => {
|
||||
export const SettingsTab: FC<{
|
||||
openingProjectId: string;
|
||||
}> = ({ openingProjectId }) => {
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="border-b border-sidebar-border p-4">
|
||||
@@ -20,7 +23,16 @@ export const SettingsTab: FC = () => {
|
||||
Session Display
|
||||
</h3>
|
||||
|
||||
<SettingsControls />
|
||||
<SettingsControls openingProjectId={openingProjectId} />
|
||||
</div>
|
||||
|
||||
{/* Notification Settings */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="font-medium text-sm text-sidebar-foreground">
|
||||
Notifications
|
||||
</h3>
|
||||
|
||||
<NotificationSettings />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { honoClient } from "@/lib/api/client";
|
||||
|
||||
export const useGitBranches = (projectId: string) => {
|
||||
return useQuery({
|
||||
queryKey: ["git", "branches", projectId],
|
||||
queryFn: async () => {
|
||||
const response = await honoClient.api.projects[
|
||||
":projectId"
|
||||
].git.branches.$get({
|
||||
param: { projectId },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch branches: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
staleTime: 30000, // 30 seconds
|
||||
});
|
||||
};
|
||||
|
||||
export const useGitCommits = (projectId: string) => {
|
||||
return useQuery({
|
||||
queryKey: ["git", "commits", projectId],
|
||||
queryFn: async () => {
|
||||
const response = await honoClient.api.projects[
|
||||
":projectId"
|
||||
].git.commits.$get({
|
||||
param: { projectId },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch commits: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
staleTime: 30000, // 30 seconds
|
||||
});
|
||||
};
|
||||
|
||||
export const useGitDiff = () => {
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
projectId,
|
||||
fromRef,
|
||||
toRef,
|
||||
}: {
|
||||
projectId: string;
|
||||
fromRef: string;
|
||||
toRef: string;
|
||||
}) => {
|
||||
const response = await honoClient.api.projects[
|
||||
":projectId"
|
||||
].git.diff.$post({
|
||||
param: { projectId },
|
||||
json: { fromRef, toRef },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to get diff: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
});
|
||||
};
|
||||
104
src/components/NotificationSettings.tsx
Normal file
104
src/components/NotificationSettings.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
"use client";
|
||||
|
||||
import { useAtom } from "jotai";
|
||||
import { type FC, useCallback, useId } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
type NotificationSoundType,
|
||||
notificationSettingsAtom,
|
||||
} from "@/lib/atoms/notifications";
|
||||
import {
|
||||
getAvailableSoundTypes,
|
||||
getSoundDisplayName,
|
||||
playNotificationSound,
|
||||
} from "@/lib/notifications";
|
||||
|
||||
interface NotificationSettingsProps {
|
||||
showLabels?: boolean;
|
||||
showDescriptions?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const NotificationSettings: FC<NotificationSettingsProps> = ({
|
||||
showLabels = true,
|
||||
showDescriptions = true,
|
||||
className = "",
|
||||
}: NotificationSettingsProps) => {
|
||||
const selectId = useId();
|
||||
const [settings, setSettings] = useAtom(notificationSettingsAtom);
|
||||
|
||||
const handleSoundTypeChange = useCallback(
|
||||
(value: NotificationSoundType) => {
|
||||
setSettings((prev) => ({
|
||||
...prev,
|
||||
soundType: value,
|
||||
}));
|
||||
},
|
||||
[setSettings],
|
||||
);
|
||||
|
||||
const handleTestSound = useCallback(() => {
|
||||
if (settings.soundType !== "none") {
|
||||
playNotificationSound(settings.soundType);
|
||||
}
|
||||
}, [settings.soundType]);
|
||||
|
||||
const availableSoundTypes = getAvailableSoundTypes();
|
||||
|
||||
return (
|
||||
<div className={`space-y-4 ${className}`}>
|
||||
<div className="space-y-2">
|
||||
{showLabels && (
|
||||
<label
|
||||
htmlFor={selectId}
|
||||
className="text-sm font-medium leading-none"
|
||||
>
|
||||
Task completion sound
|
||||
</label>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
value={settings.soundType}
|
||||
onValueChange={handleSoundTypeChange}
|
||||
>
|
||||
<SelectTrigger id={selectId} className="w-[180px]">
|
||||
<SelectValue placeholder="音を選択" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableSoundTypes.map((soundType) => (
|
||||
<SelectItem key={soundType} value={soundType}>
|
||||
{getSoundDisplayName(soundType)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{settings.soundType !== "none" && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleTestSound}
|
||||
className="px-3"
|
||||
>
|
||||
テスト
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showDescriptions && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Claude Code のタスクが完了した時に再生する音を選択してください
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,40 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { type FC, useId } from "react";
|
||||
import { type FC, useCallback, useId } from "react";
|
||||
import { configQueryConfig, useConfig } from "@/app/hooks/useConfig";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { projectQueryConfig } from "../app/projects/[projectId]/hooks/useProject";
|
||||
|
||||
interface SettingsControlsProps {
|
||||
openingProjectId: string;
|
||||
showLabels?: boolean;
|
||||
showDescriptions?: boolean;
|
||||
className?: string;
|
||||
onConfigChange?: () => void;
|
||||
}
|
||||
|
||||
export const SettingsControls: FC<SettingsControlsProps> = ({
|
||||
openingProjectId,
|
||||
showLabels = true,
|
||||
showDescriptions = true,
|
||||
className = "",
|
||||
onConfigChange,
|
||||
}: SettingsControlsProps) => {
|
||||
const checkboxId = useId();
|
||||
const enterKeyBehaviorId = useId();
|
||||
const { config, updateConfig } = useConfig();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const onConfigChanged = useCallback(async () => {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: configQueryConfig.queryKey,
|
||||
});
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["projects"],
|
||||
});
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: projectQueryConfig(openingProjectId).queryKey,
|
||||
});
|
||||
}, [queryClient, openingProjectId]);
|
||||
|
||||
const handleHideNoUserMessageChange = async () => {
|
||||
const newConfig = {
|
||||
...config,
|
||||
hideNoUserMessageSession: !config?.hideNoUserMessageSession,
|
||||
};
|
||||
updateConfig(newConfig);
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: configQueryConfig.queryKey,
|
||||
});
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["projects"],
|
||||
});
|
||||
onConfigChange?.();
|
||||
await onConfigChanged();
|
||||
};
|
||||
|
||||
const handleUnifySameTitleChange = async () => {
|
||||
@@ -43,13 +58,16 @@ export const SettingsControls: FC<SettingsControlsProps> = ({
|
||||
unifySameTitleSession: !config?.unifySameTitleSession,
|
||||
};
|
||||
updateConfig(newConfig);
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: configQueryConfig.queryKey,
|
||||
});
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["projects"],
|
||||
});
|
||||
onConfigChange?.();
|
||||
await onConfigChanged();
|
||||
};
|
||||
|
||||
const handleEnterKeyBehaviorChange = async (value: string) => {
|
||||
const newConfig = {
|
||||
...config,
|
||||
enterKeyBehavior: value as "shift-enter-send" | "enter-send",
|
||||
};
|
||||
updateConfig(newConfig);
|
||||
await onConfigChanged();
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -96,6 +114,36 @@ export const SettingsControls: FC<SettingsControlsProps> = ({
|
||||
title
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{showLabels && (
|
||||
<label
|
||||
htmlFor={enterKeyBehaviorId}
|
||||
className="text-sm font-medium leading-none"
|
||||
>
|
||||
Enter Key Behavior
|
||||
</label>
|
||||
)}
|
||||
<Select
|
||||
value={config?.enterKeyBehavior || "shift-enter-send"}
|
||||
onValueChange={handleEnterKeyBehaviorChange}
|
||||
>
|
||||
<SelectTrigger id={enterKeyBehaviorId} className="w-full">
|
||||
<SelectValue placeholder="Select enter key behavior" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="shift-enter-send">
|
||||
Shift+Enter to send (default)
|
||||
</SelectItem>
|
||||
<SelectItem value="enter-send">Enter to send</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{showDescriptions && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Choose how the Enter key behaves in message input
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -60,7 +60,7 @@ function DialogContent({
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
175
src/components/ui/select.tsx
Normal file
175
src/components/ui/select.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
"use client";
|
||||
|
||||
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||
import { Check, ChevronDown, ChevronUp } from "lucide-react";
|
||||
import type * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Select({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
return <SelectPrimitive.Root {...props} />;
|
||||
}
|
||||
|
||||
function SelectGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
return <SelectPrimitive.Group {...props} />;
|
||||
}
|
||||
|
||||
function SelectValue({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
return <SelectPrimitive.Value {...props} />;
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger>) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
className={cn(
|
||||
"flex h-10 w-full items-center justify-between rounded-md border bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
position = "popper",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
className={cn(
|
||||
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className,
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
};
|
||||
25
src/components/ui/sonner.tsx
Normal file
25
src/components/ui/sonner.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import { useTheme } from "next-themes";
|
||||
import { Toaster as Sonner, type ToasterProps } from "sonner";
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const { theme = "system" } = useTheme();
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme as ToasterProps["theme"]}
|
||||
className="toaster group"
|
||||
style={
|
||||
{
|
||||
"--normal-bg": "var(--popover)",
|
||||
"--normal-text": "var(--popover-foreground)",
|
||||
"--normal-border": "var(--border)",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export { Toaster };
|
||||
40
src/hooks/useFileCompletion.ts
Normal file
40
src/hooks/useFileCompletion.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { honoClient } from "../lib/api/client";
|
||||
|
||||
export type FileCompletionEntry = {
|
||||
name: string;
|
||||
type: "file" | "directory";
|
||||
path: string;
|
||||
};
|
||||
|
||||
export type FileCompletionResult = {
|
||||
entries: FileCompletionEntry[];
|
||||
basePath: string;
|
||||
projectPath: string;
|
||||
};
|
||||
|
||||
export const useFileCompletion = (
|
||||
projectId: string,
|
||||
basePath: string,
|
||||
enabled = true,
|
||||
) => {
|
||||
return useQuery({
|
||||
queryKey: ["file-completion", projectId, basePath],
|
||||
queryFn: async (): Promise<FileCompletionResult> => {
|
||||
const response = await honoClient.api.projects[":projectId"][
|
||||
"file-completion"
|
||||
].$get({
|
||||
param: { projectId },
|
||||
query: { basePath },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch file completion");
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
enabled: enabled && !!projectId,
|
||||
staleTime: 1000 * 60 * 5, // 5分間キャッシュ
|
||||
});
|
||||
};
|
||||
39
src/hooks/useTaskNotifications.ts
Normal file
39
src/hooks/useTaskNotifications.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
notificationSettingsAtom,
|
||||
soundNotificationsEnabledAtom,
|
||||
} from "@/lib/atoms/notifications";
|
||||
import { playNotificationSound } from "@/lib/notifications";
|
||||
|
||||
/**
|
||||
* Hook to handle task completion sound notifications
|
||||
* Monitors task state changes and triggers sound when tasks complete
|
||||
*/
|
||||
export const useTaskNotifications = (isRunningTask: boolean) => {
|
||||
const settings = useAtomValue(notificationSettingsAtom);
|
||||
const soundEnabled = useAtomValue(soundNotificationsEnabledAtom);
|
||||
|
||||
// Track previous running state to detect completion
|
||||
const prevIsRunningRef = useRef<boolean>(isRunningTask);
|
||||
|
||||
// Monitor task state changes
|
||||
useEffect(() => {
|
||||
const prevIsRunning = prevIsRunningRef.current;
|
||||
const currentIsRunning = isRunningTask;
|
||||
|
||||
// Update the ref for next comparison
|
||||
prevIsRunningRef.current = currentIsRunning;
|
||||
|
||||
// Detect task completion: was running, now not running
|
||||
if (prevIsRunning && !currentIsRunning) {
|
||||
toast.success("Task completed");
|
||||
|
||||
if (soundEnabled) {
|
||||
// Play notification sound
|
||||
playNotificationSound(settings.soundType);
|
||||
}
|
||||
}
|
||||
}, [isRunningTask, soundEnabled, settings.soundType]);
|
||||
};
|
||||
34
src/lib/atoms/notifications.ts
Normal file
34
src/lib/atoms/notifications.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { atom } from "jotai";
|
||||
import { atomWithStorage } from "jotai/utils";
|
||||
|
||||
/**
|
||||
* Available sound types for notifications
|
||||
*/
|
||||
export type NotificationSoundType = "none" | "beep" | "chime" | "ping" | "pop";
|
||||
|
||||
/**
|
||||
* Notification settings stored in localStorage
|
||||
*/
|
||||
export interface NotificationSettings {
|
||||
soundType: NotificationSoundType;
|
||||
}
|
||||
|
||||
const defaultSettings: NotificationSettings = {
|
||||
soundType: "none",
|
||||
};
|
||||
|
||||
/**
|
||||
* Atom for notification settings with localStorage persistence
|
||||
*/
|
||||
export const notificationSettingsAtom = atomWithStorage<NotificationSettings>(
|
||||
"claude-code-viewer-notification-settings",
|
||||
defaultSettings,
|
||||
);
|
||||
|
||||
/**
|
||||
* Derived atom to check if sound notifications are enabled
|
||||
*/
|
||||
export const soundNotificationsEnabledAtom = atom((get) => {
|
||||
const settings = get(notificationSettingsAtom);
|
||||
return settings.soundType !== "none";
|
||||
});
|
||||
@@ -1,12 +1,10 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const ImageContentSchema = z
|
||||
.object({
|
||||
type: z.literal("image"),
|
||||
source: z.object({
|
||||
type: z.literal("base64"),
|
||||
data: z.string(),
|
||||
media_type: z.enum(["image/png"]),
|
||||
}),
|
||||
})
|
||||
.strict();
|
||||
export const ImageContentSchema = z.object({
|
||||
type: z.literal("image"),
|
||||
source: z.object({
|
||||
type: z.literal("base64"),
|
||||
data: z.string(),
|
||||
media_type: z.enum(["image/png"]),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const TextContentSchema = z
|
||||
.object({
|
||||
type: z.literal("text"),
|
||||
text: z.string(),
|
||||
})
|
||||
.strict();
|
||||
export const TextContentSchema = z.object({
|
||||
type: z.literal("text"),
|
||||
text: z.string(),
|
||||
});
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const ThinkingContentSchema = z
|
||||
.object({
|
||||
type: z.literal("thinking"),
|
||||
thinking: z.string(),
|
||||
signature: z.string().optional(),
|
||||
})
|
||||
.strict();
|
||||
export const ThinkingContentSchema = z.object({
|
||||
type: z.literal("thinking"),
|
||||
thinking: z.string(),
|
||||
signature: z.string().optional(),
|
||||
});
|
||||
|
||||
@@ -2,16 +2,14 @@ import { z } from "zod";
|
||||
import { ImageContentSchema } from "./ImageContentSchema";
|
||||
import { TextContentSchema } from "./TextContentSchema";
|
||||
|
||||
export const ToolResultContentSchema = z
|
||||
.object({
|
||||
type: z.literal("tool_result"),
|
||||
tool_use_id: z.string(),
|
||||
content: z.union([
|
||||
z.string(),
|
||||
z.array(z.union([TextContentSchema, ImageContentSchema])),
|
||||
]),
|
||||
is_error: z.boolean().optional(),
|
||||
})
|
||||
.strict();
|
||||
export const ToolResultContentSchema = z.object({
|
||||
type: z.literal("tool_result"),
|
||||
tool_use_id: z.string(),
|
||||
content: z.union([
|
||||
z.string(),
|
||||
z.array(z.union([TextContentSchema, ImageContentSchema])),
|
||||
]),
|
||||
is_error: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export type ToolResultContent = z.infer<typeof ToolResultContentSchema>;
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const ToolUseContentSchema = z
|
||||
.object({
|
||||
type: z.literal("tool_use"),
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
input: z.record(z.string(), z.unknown()),
|
||||
})
|
||||
.strict();
|
||||
export const ToolUseContentSchema = z.object({
|
||||
type: z.literal("tool_use"),
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
input: z.record(z.string(), z.unknown()),
|
||||
});
|
||||
|
||||
@@ -12,4 +12,4 @@ export const AssistantEntrySchema = BaseEntrySchema.extend({
|
||||
// optional
|
||||
requestId: z.string().optional(),
|
||||
isApiErrorMessage: z.boolean().optional(),
|
||||
}).strict();
|
||||
});
|
||||
|
||||
@@ -1,23 +1,21 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const BaseEntrySchema = z
|
||||
.object({
|
||||
// required
|
||||
isSidechain: z.boolean(),
|
||||
userType: z.enum(["external"]),
|
||||
cwd: z.string(),
|
||||
sessionId: z.string(),
|
||||
version: z.string(),
|
||||
uuid: z.uuid(),
|
||||
timestamp: z.string(),
|
||||
export const BaseEntrySchema = z.object({
|
||||
// required
|
||||
isSidechain: z.boolean(),
|
||||
userType: z.enum(["external"]),
|
||||
cwd: z.string(),
|
||||
sessionId: z.string(),
|
||||
version: z.string(),
|
||||
uuid: z.uuid(),
|
||||
timestamp: z.string(),
|
||||
|
||||
// nullable
|
||||
parentUuid: z.uuid().nullable(),
|
||||
// nullable
|
||||
parentUuid: z.uuid().nullable(),
|
||||
|
||||
// optional
|
||||
isMeta: z.boolean().optional(),
|
||||
toolUseResult: z.unknown().optional(), // スキーマがツールごとに異なりすぎるし利用もしなそうなので unknown
|
||||
gitBranch: z.string().optional(),
|
||||
isCompactSummary: z.boolean().optional(),
|
||||
})
|
||||
.strict();
|
||||
// optional
|
||||
isMeta: z.boolean().optional(),
|
||||
toolUseResult: z.unknown().optional(), // スキーマがツールごとに異なりすぎるし利用もしなそうなので unknown
|
||||
gitBranch: z.string().optional(),
|
||||
isCompactSummary: z.boolean().optional(),
|
||||
});
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const SummaryEntrySchema = z
|
||||
.object({
|
||||
type: z.literal("summary"),
|
||||
summary: z.string(),
|
||||
leafUuid: z.string().uuid(),
|
||||
})
|
||||
.strict();
|
||||
export const SummaryEntrySchema = z.object({
|
||||
type: z.literal("summary"),
|
||||
summary: z.string(),
|
||||
leafUuid: z.string().uuid(),
|
||||
});
|
||||
|
||||
@@ -9,4 +9,4 @@ export const SystemEntrySchema = BaseEntrySchema.extend({
|
||||
content: z.string(),
|
||||
toolUseID: z.string(),
|
||||
level: z.enum(["info"]),
|
||||
}).strict();
|
||||
});
|
||||
|
||||
@@ -8,4 +8,4 @@ export const UserEntrySchema = BaseEntrySchema.extend({
|
||||
|
||||
// required
|
||||
message: UserMessageSchema,
|
||||
}).strict();
|
||||
});
|
||||
|
||||
@@ -15,32 +15,31 @@ export type AssistantMessageContent = z.infer<
|
||||
typeof AssistantMessageContentSchema
|
||||
>;
|
||||
|
||||
export const AssistantMessageSchema = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
type: z.literal("message"),
|
||||
role: z.literal("assistant"),
|
||||
model: z.string(),
|
||||
content: z.array(AssistantMessageContentSchema),
|
||||
stop_reason: z.string().nullable(),
|
||||
stop_sequence: z.string().nullable(),
|
||||
usage: z.object({
|
||||
input_tokens: z.number(),
|
||||
cache_creation_input_tokens: z.number().optional(),
|
||||
cache_read_input_tokens: z.number().optional(),
|
||||
cache_creation: z
|
||||
.object({
|
||||
ephemeral_5m_input_tokens: z.number(),
|
||||
ephemeral_1h_input_tokens: z.number(),
|
||||
})
|
||||
.optional(),
|
||||
output_tokens: z.number(),
|
||||
service_tier: z.string().nullable().optional(),
|
||||
server_tool_use: z
|
||||
.object({
|
||||
web_search_requests: z.number(),
|
||||
})
|
||||
.optional(),
|
||||
}),
|
||||
})
|
||||
.strict();
|
||||
export const AssistantMessageSchema = z.object({
|
||||
id: z.string(),
|
||||
container: z.null().optional(),
|
||||
type: z.literal("message"),
|
||||
role: z.literal("assistant"),
|
||||
model: z.string(),
|
||||
content: z.array(AssistantMessageContentSchema),
|
||||
stop_reason: z.string().nullable(),
|
||||
stop_sequence: z.string().nullable(),
|
||||
usage: z.object({
|
||||
input_tokens: z.number(),
|
||||
cache_creation_input_tokens: z.number().optional(),
|
||||
cache_read_input_tokens: z.number().optional(),
|
||||
cache_creation: z
|
||||
.object({
|
||||
ephemeral_5m_input_tokens: z.number(),
|
||||
ephemeral_1h_input_tokens: z.number(),
|
||||
})
|
||||
.optional(),
|
||||
output_tokens: z.number(),
|
||||
service_tier: z.string().nullable().optional(),
|
||||
server_tool_use: z
|
||||
.object({
|
||||
web_search_requests: z.number(),
|
||||
})
|
||||
.optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -12,12 +12,10 @@ const UserMessageContentSchema = z.union([
|
||||
|
||||
export type UserMessageContent = z.infer<typeof UserMessageContentSchema>;
|
||||
|
||||
export const UserMessageSchema = z
|
||||
.object({
|
||||
role: z.literal("user"),
|
||||
content: z.union([
|
||||
z.string(),
|
||||
z.array(z.union([z.string(), UserMessageContentSchema])),
|
||||
]),
|
||||
})
|
||||
.strict();
|
||||
export const UserMessageSchema = z.object({
|
||||
role: z.literal("user"),
|
||||
content: z.union([
|
||||
z.string(),
|
||||
z.array(z.union([z.string(), UserMessageContentSchema])),
|
||||
]),
|
||||
});
|
||||
|
||||
@@ -2,70 +2,58 @@ import { z } from "zod";
|
||||
import { StructuredPatchSchema } from "./StructuredPatchSchema";
|
||||
|
||||
export const CommonToolResultSchema = z.union([
|
||||
z
|
||||
.object({
|
||||
stdout: z.string(),
|
||||
stderr: z.string(),
|
||||
interrupted: z.boolean(),
|
||||
isImage: z.boolean(),
|
||||
})
|
||||
.strict(),
|
||||
z.object({
|
||||
stdout: z.string(),
|
||||
stderr: z.string(),
|
||||
interrupted: z.boolean(),
|
||||
isImage: z.boolean(),
|
||||
}),
|
||||
|
||||
// create
|
||||
z
|
||||
.object({
|
||||
type: z.literal("create"),
|
||||
filePath: z.string(),
|
||||
content: z.string(),
|
||||
structuredPatch: z.array(StructuredPatchSchema),
|
||||
})
|
||||
.strict(),
|
||||
z.object({
|
||||
type: z.literal("create"),
|
||||
filePath: z.string(),
|
||||
content: z.string(),
|
||||
structuredPatch: z.array(StructuredPatchSchema),
|
||||
}),
|
||||
|
||||
// update
|
||||
z
|
||||
.object({
|
||||
filePath: z.string(),
|
||||
oldString: z.string(),
|
||||
newString: z.string(),
|
||||
originalFile: z.string(),
|
||||
userModified: z.boolean(),
|
||||
replaceAll: z.boolean(),
|
||||
structuredPatch: z.array(StructuredPatchSchema),
|
||||
})
|
||||
.strict(),
|
||||
z.object({
|
||||
filePath: z.string(),
|
||||
oldString: z.string(),
|
||||
newString: z.string(),
|
||||
originalFile: z.string(),
|
||||
userModified: z.boolean(),
|
||||
replaceAll: z.boolean(),
|
||||
structuredPatch: z.array(StructuredPatchSchema),
|
||||
}),
|
||||
|
||||
// search?
|
||||
z
|
||||
.object({
|
||||
filenames: z.array(z.string()),
|
||||
durationMs: z.number(),
|
||||
numFiles: z.number(),
|
||||
truncated: z.boolean(),
|
||||
})
|
||||
.strict(),
|
||||
z.object({
|
||||
filenames: z.array(z.string()),
|
||||
durationMs: z.number(),
|
||||
numFiles: z.number(),
|
||||
truncated: z.boolean(),
|
||||
}),
|
||||
|
||||
// text
|
||||
z
|
||||
.object({
|
||||
type: z.literal("text"),
|
||||
file: z.object({
|
||||
filePath: z.string(),
|
||||
content: z.string(),
|
||||
numLines: z.number(),
|
||||
startLine: z.number(),
|
||||
totalLines: z.number(),
|
||||
}),
|
||||
})
|
||||
.strict(),
|
||||
|
||||
// content
|
||||
z
|
||||
.object({
|
||||
mode: z.literal("content"),
|
||||
numFiles: z.number(),
|
||||
filenames: z.array(z.string()),
|
||||
z.object({
|
||||
type: z.literal("text"),
|
||||
file: z.object({
|
||||
filePath: z.string(),
|
||||
content: z.string(),
|
||||
numLines: z.number(),
|
||||
})
|
||||
.strict(),
|
||||
startLine: z.number(),
|
||||
totalLines: z.number(),
|
||||
}),
|
||||
}),
|
||||
|
||||
// content
|
||||
z.object({
|
||||
mode: z.literal("content"),
|
||||
numFiles: z.number(),
|
||||
filenames: z.array(z.string()),
|
||||
content: z.string(),
|
||||
numLines: z.number(),
|
||||
}),
|
||||
]);
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const StructuredPatchSchema = z
|
||||
.object({
|
||||
oldStart: z.number(),
|
||||
oldLines: z.number(),
|
||||
newStart: z.number(),
|
||||
newLines: z.number(),
|
||||
lines: z.array(z.string()),
|
||||
})
|
||||
.strict();
|
||||
export const StructuredPatchSchema = z.object({
|
||||
oldStart: z.number(),
|
||||
oldLines: z.number(),
|
||||
newStart: z.number(),
|
||||
newLines: z.number(),
|
||||
lines: z.array(z.string()),
|
||||
});
|
||||
|
||||
@@ -1,17 +1,13 @@
|
||||
import z from "zod";
|
||||
|
||||
const TodoSchema = z
|
||||
.object({
|
||||
content: z.string(),
|
||||
status: z.enum(["pending", "in_progress", "completed"]),
|
||||
priority: z.enum(["low", "medium", "high"]),
|
||||
id: z.string(),
|
||||
})
|
||||
.strict();
|
||||
const TodoSchema = z.object({
|
||||
content: z.string(),
|
||||
status: z.enum(["pending", "in_progress", "completed"]),
|
||||
priority: z.enum(["low", "medium", "high"]),
|
||||
id: z.string(),
|
||||
});
|
||||
|
||||
export const TodoToolResultSchema = z
|
||||
.object({
|
||||
oldTodos: z.array(TodoSchema).optional(),
|
||||
newTodos: z.array(TodoSchema).optional(),
|
||||
})
|
||||
.strict();
|
||||
export const TodoToolResultSchema = z.object({
|
||||
oldTodos: z.array(TodoSchema).optional(),
|
||||
newTodos: z.array(TodoSchema).optional(),
|
||||
});
|
||||
|
||||
114
src/lib/notifications.ts
Normal file
114
src/lib/notifications.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Audio notification utilities for task completion alerts
|
||||
*/
|
||||
|
||||
import type { NotificationSoundType } from "./atoms/notifications";
|
||||
|
||||
/**
|
||||
* Sound configuration for different notification types
|
||||
*/
|
||||
const soundConfigs: Record<
|
||||
Exclude<NotificationSoundType, "none">,
|
||||
{
|
||||
frequency: number[];
|
||||
duration: number;
|
||||
type: OscillatorType;
|
||||
volume: number;
|
||||
}
|
||||
> = {
|
||||
beep: {
|
||||
frequency: [800],
|
||||
duration: 0.15,
|
||||
type: "sine",
|
||||
volume: 0.3,
|
||||
},
|
||||
chime: {
|
||||
frequency: [523, 659, 784], // C, E, G notes
|
||||
duration: 0.4,
|
||||
type: "sine",
|
||||
volume: 0.2,
|
||||
},
|
||||
ping: {
|
||||
frequency: [1000],
|
||||
duration: 0.1,
|
||||
type: "triangle",
|
||||
volume: 0.4,
|
||||
},
|
||||
pop: {
|
||||
frequency: [400, 600],
|
||||
duration: 0.08,
|
||||
type: "square",
|
||||
volume: 0.2,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Play a notification sound based on the sound type
|
||||
*/
|
||||
export function playNotificationSound(soundType: NotificationSoundType) {
|
||||
if (soundType === "none") {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const config = soundConfigs[soundType];
|
||||
if (!config) {
|
||||
console.warn(`Unknown sound type: ${soundType}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const audioContext = new (
|
||||
window.AudioContext ||
|
||||
(window as unknown as { webkitAudioContext: typeof AudioContext })
|
||||
.webkitAudioContext
|
||||
)();
|
||||
|
||||
// Play multiple frequencies if specified (for chords/sequences)
|
||||
config.frequency.forEach((freq, index) => {
|
||||
const oscillator = audioContext.createOscillator();
|
||||
const gainNode = audioContext.createGain();
|
||||
|
||||
oscillator.connect(gainNode);
|
||||
gainNode.connect(audioContext.destination);
|
||||
|
||||
oscillator.frequency.setValueAtTime(freq, audioContext.currentTime);
|
||||
oscillator.type = config.type;
|
||||
|
||||
// Set volume and fade out
|
||||
const startTime = audioContext.currentTime + index * 0.05; // Slight delay for sequences
|
||||
gainNode.gain.setValueAtTime(config.volume, startTime);
|
||||
gainNode.gain.exponentialRampToValueAtTime(
|
||||
0.01,
|
||||
startTime + config.duration,
|
||||
);
|
||||
|
||||
// Play the sound
|
||||
oscillator.start(startTime);
|
||||
oscillator.stop(startTime + config.duration);
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn("Failed to play notification sound:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get display name for sound types
|
||||
*/
|
||||
export function getSoundDisplayName(soundType: NotificationSoundType): string {
|
||||
const displayNames: Record<NotificationSoundType, string> = {
|
||||
none: "なし",
|
||||
beep: "ビープ",
|
||||
chime: "チャイム",
|
||||
ping: "ピン",
|
||||
pop: "ポップ",
|
||||
};
|
||||
|
||||
return displayNames[soundType];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available sound types
|
||||
*/
|
||||
export function getAvailableSoundTypes(): NotificationSoundType[] {
|
||||
return ["none", "beep", "chime", "ping", "pop"];
|
||||
}
|
||||
@@ -3,6 +3,10 @@ import z from "zod";
|
||||
export const configSchema = z.object({
|
||||
hideNoUserMessageSession: z.boolean().optional().default(true),
|
||||
unifySameTitleSession: z.boolean().optional().default(true),
|
||||
enterKeyBehavior: z
|
||||
.enum(["shift-enter-send", "enter-send"])
|
||||
.optional()
|
||||
.default("shift-enter-send"),
|
||||
});
|
||||
|
||||
export type Config = z.infer<typeof configSchema>;
|
||||
|
||||
@@ -11,6 +11,10 @@ import type { SerializableAliveTask } from "../service/claude-code/types";
|
||||
import { getEventBus } from "../service/events/EventBus";
|
||||
import { getFileWatcher } from "../service/events/fileWatcher";
|
||||
import { sseEventResponse } from "../service/events/sseEventResponse";
|
||||
import { getFileCompletion } from "../service/file-completion/getFileCompletion";
|
||||
import { getBranches } from "../service/git/getBranches";
|
||||
import { getCommits } from "../service/git/getCommits";
|
||||
import { getDiff } from "../service/git/getDiff";
|
||||
import { getMcpList } from "../service/mcp/getMcpList";
|
||||
import { getProject } from "../service/project/getProject";
|
||||
import { getProjects } from "../service/project/getProjects";
|
||||
@@ -135,6 +139,37 @@ export const routes = (app: HonoAppType) => {
|
||||
return c.json({ session });
|
||||
})
|
||||
|
||||
.get(
|
||||
"/projects/:projectId/file-completion",
|
||||
zValidator(
|
||||
"query",
|
||||
z.object({
|
||||
basePath: z.string().optional().default("/"),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const { projectId } = c.req.param();
|
||||
const { basePath } = c.req.valid("query");
|
||||
|
||||
const { project } = await getProject(projectId);
|
||||
|
||||
if (project.meta.projectPath === null) {
|
||||
return c.json({ error: "Project path not found" }, 400);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await getFileCompletion(
|
||||
project.meta.projectPath,
|
||||
basePath,
|
||||
);
|
||||
return c.json(result);
|
||||
} catch (error) {
|
||||
console.error("File completion error:", error);
|
||||
return c.json({ error: "Failed to get file completion" }, 500);
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
.get("/projects/:projectId/claude-commands", async (c) => {
|
||||
const { projectId } = c.req.param();
|
||||
const { project } = await getProject(projectId);
|
||||
@@ -170,6 +205,81 @@ export const routes = (app: HonoAppType) => {
|
||||
});
|
||||
})
|
||||
|
||||
.get("/projects/:projectId/git/branches", async (c) => {
|
||||
const { projectId } = c.req.param();
|
||||
const { project } = await getProject(projectId);
|
||||
|
||||
if (project.meta.projectPath === null) {
|
||||
return c.json({ error: "Project path not found" }, 400);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await getBranches(project.meta.projectPath);
|
||||
return c.json(result);
|
||||
} catch (error) {
|
||||
console.error("Get branches error:", error);
|
||||
if (error instanceof Error) {
|
||||
return c.json({ error: error.message }, 400);
|
||||
}
|
||||
return c.json({ error: "Failed to get branches" }, 500);
|
||||
}
|
||||
})
|
||||
|
||||
.get("/projects/:projectId/git/commits", async (c) => {
|
||||
const { projectId } = c.req.param();
|
||||
const { project } = await getProject(projectId);
|
||||
|
||||
if (project.meta.projectPath === null) {
|
||||
return c.json({ error: "Project path not found" }, 400);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await getCommits(project.meta.projectPath);
|
||||
return c.json(result);
|
||||
} catch (error) {
|
||||
console.error("Get commits error:", error);
|
||||
if (error instanceof Error) {
|
||||
return c.json({ error: error.message }, 400);
|
||||
}
|
||||
return c.json({ error: "Failed to get commits" }, 500);
|
||||
}
|
||||
})
|
||||
|
||||
.post(
|
||||
"/projects/:projectId/git/diff",
|
||||
zValidator(
|
||||
"json",
|
||||
z.object({
|
||||
fromRef: z.string().min(1, "fromRef is required"),
|
||||
toRef: z.string().min(1, "toRef is required"),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const { projectId } = c.req.param();
|
||||
const { fromRef, toRef } = c.req.valid("json");
|
||||
const { project } = await getProject(projectId);
|
||||
|
||||
if (project.meta.projectPath === null) {
|
||||
return c.json({ error: "Project path not found" }, 400);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await getDiff(
|
||||
project.meta.projectPath,
|
||||
fromRef,
|
||||
toRef,
|
||||
);
|
||||
return c.json(result);
|
||||
} catch (error) {
|
||||
console.error("Get diff error:", error);
|
||||
if (error instanceof Error) {
|
||||
return c.json({ error: error.message }, 400);
|
||||
}
|
||||
return c.json({ error: "Failed to get diff" }, 500);
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
.get("/mcp/list", async (c) => {
|
||||
const { servers } = await getMcpList();
|
||||
return c.json({ servers });
|
||||
|
||||
96
src/server/service/file-completion/getFileCompletion.ts
Normal file
96
src/server/service/file-completion/getFileCompletion.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { readdir } from "node:fs/promises";
|
||||
import { join, resolve } from "node:path";
|
||||
|
||||
export type FileCompletionEntry = {
|
||||
name: string;
|
||||
type: "file" | "directory";
|
||||
path: string;
|
||||
};
|
||||
|
||||
export type FileCompletionResult = {
|
||||
entries: FileCompletionEntry[];
|
||||
basePath: string;
|
||||
projectPath: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get file and directory completions for a given project path
|
||||
* @param projectPath - The root project path
|
||||
* @param basePath - The relative path from project root (default: "/")
|
||||
* @returns File and directory entries at the specified path level
|
||||
*/
|
||||
export const getFileCompletion = async (
|
||||
projectPath: string,
|
||||
basePath = "/",
|
||||
): Promise<FileCompletionResult> => {
|
||||
// Normalize basePath to prevent directory traversal
|
||||
const normalizedBasePath = basePath.startsWith("/")
|
||||
? basePath.slice(1)
|
||||
: basePath;
|
||||
const targetPath = resolve(projectPath, normalizedBasePath);
|
||||
|
||||
// Security check: ensure target path is within project directory
|
||||
if (!targetPath.startsWith(resolve(projectPath))) {
|
||||
throw new Error("Invalid path: outside project directory");
|
||||
}
|
||||
|
||||
// Check if the target path exists
|
||||
if (!existsSync(targetPath)) {
|
||||
return {
|
||||
entries: [],
|
||||
basePath: normalizedBasePath,
|
||||
projectPath,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const dirents = await readdir(targetPath, { withFileTypes: true });
|
||||
const entries: FileCompletionEntry[] = [];
|
||||
|
||||
// Process each directory entry
|
||||
for (const dirent of dirents) {
|
||||
// Skip hidden files and directories (starting with .)
|
||||
if (dirent.name.startsWith(".")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const entryPath = join(normalizedBasePath, dirent.name);
|
||||
|
||||
if (dirent.isDirectory()) {
|
||||
entries.push({
|
||||
name: dirent.name,
|
||||
type: "directory",
|
||||
path: entryPath,
|
||||
});
|
||||
} else if (dirent.isFile()) {
|
||||
entries.push({
|
||||
name: dirent.name,
|
||||
type: "file",
|
||||
path: entryPath,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Sort entries: directories first, then files, both alphabetically
|
||||
entries.sort((a, b) => {
|
||||
if (a.type !== b.type) {
|
||||
return a.type === "directory" ? -1 : 1;
|
||||
}
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
return {
|
||||
entries,
|
||||
basePath: normalizedBasePath,
|
||||
projectPath,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error reading directory:", error);
|
||||
return {
|
||||
entries: [],
|
||||
basePath: normalizedBasePath,
|
||||
projectPath,
|
||||
};
|
||||
}
|
||||
};
|
||||
130
src/server/service/git/getBranches.ts
Normal file
130
src/server/service/git/getBranches.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import type { GitBranch, GitResult } from "./types";
|
||||
import { executeGitCommand, parseLines } from "./utils";
|
||||
|
||||
/**
|
||||
* Get all branches (local and remote) in the repository
|
||||
*/
|
||||
export async function getBranches(
|
||||
cwd: string,
|
||||
): Promise<GitResult<GitBranch[]>> {
|
||||
// Get all branches with verbose information
|
||||
const result = await executeGitCommand(["branch", "-vv", "--all"], cwd);
|
||||
|
||||
if (!result.success) {
|
||||
return result as GitResult<GitBranch[]>;
|
||||
}
|
||||
|
||||
try {
|
||||
const lines = parseLines(result.data);
|
||||
const branches: GitBranch[] = [];
|
||||
const seenBranches = new Set<string>();
|
||||
|
||||
for (const line of lines) {
|
||||
// Parse branch line format: " main abc1234 [origin/main: ahead 1] Commit message"
|
||||
const match = line.match(
|
||||
/^(\*?\s*)([^\s]+)\s+([a-f0-9]+)(?:\s+\[([^\]]+)\])?\s*(.*)/,
|
||||
);
|
||||
if (!match) continue;
|
||||
|
||||
const [, prefix, name, commit, tracking] = match;
|
||||
if (!prefix || !name || !commit) continue;
|
||||
|
||||
const current = prefix.includes("*");
|
||||
|
||||
// Skip remote tracking branches if we already have the local branch
|
||||
const cleanName = name.replace("remotes/origin/", "");
|
||||
if (name.startsWith("remotes/origin/") && seenBranches.has(cleanName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Parse tracking information
|
||||
let remote: string | undefined;
|
||||
let ahead: number | undefined;
|
||||
let behind: number | undefined;
|
||||
|
||||
if (tracking) {
|
||||
const remoteMatch = tracking.match(/^([^:]+)/);
|
||||
if (remoteMatch?.[1]) {
|
||||
remote = remoteMatch[1];
|
||||
}
|
||||
|
||||
const aheadMatch = tracking.match(/ahead (\d+)/);
|
||||
const behindMatch = tracking.match(/behind (\d+)/);
|
||||
if (aheadMatch?.[1]) ahead = parseInt(aheadMatch[1], 10);
|
||||
if (behindMatch?.[1]) behind = parseInt(behindMatch[1], 10);
|
||||
}
|
||||
|
||||
branches.push({
|
||||
name: cleanName,
|
||||
current,
|
||||
remote,
|
||||
commit,
|
||||
ahead,
|
||||
behind,
|
||||
});
|
||||
|
||||
seenBranches.add(cleanName);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: branches,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
code: "PARSE_ERROR",
|
||||
message: `Failed to parse branch information: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current branch name
|
||||
*/
|
||||
export async function getCurrentBranch(
|
||||
cwd: string,
|
||||
): Promise<GitResult<string>> {
|
||||
const result = await executeGitCommand(["branch", "--show-current"], cwd);
|
||||
|
||||
if (!result.success) {
|
||||
return result as GitResult<string>;
|
||||
}
|
||||
|
||||
const currentBranch = result.data.trim();
|
||||
|
||||
if (!currentBranch) {
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
code: "COMMAND_FAILED",
|
||||
message: "Could not determine current branch (possibly detached HEAD)",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: currentBranch,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a branch exists
|
||||
*/
|
||||
export async function branchExists(
|
||||
cwd: string,
|
||||
branchName: string,
|
||||
): Promise<GitResult<boolean>> {
|
||||
const result = await executeGitCommand(
|
||||
["rev-parse", "--verify", branchName],
|
||||
cwd,
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: result.success,
|
||||
};
|
||||
}
|
||||
51
src/server/service/git/getCommits.ts
Normal file
51
src/server/service/git/getCommits.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import type { GitCommit, GitResult } from "./types";
|
||||
import { executeGitCommand, parseLines } from "./utils";
|
||||
|
||||
/**
|
||||
* Get the last 20 commits from the current branch
|
||||
*/
|
||||
export async function getCommits(cwd: string): Promise<GitResult<GitCommit[]>> {
|
||||
// Get commits with oneline format and limit to 20
|
||||
const result = await executeGitCommand(
|
||||
["log", "--oneline", "-n", "20", "--format=%H|%s|%an|%ad", "--date=iso"],
|
||||
cwd,
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
return result as GitResult<GitCommit[]>;
|
||||
}
|
||||
|
||||
try {
|
||||
const lines = parseLines(result.data);
|
||||
const commits: GitCommit[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
// Parse commit line format: "sha|message|author|date"
|
||||
const parts = line.split("|");
|
||||
if (parts.length < 4) continue;
|
||||
|
||||
const [sha, message, author, date] = parts;
|
||||
if (!sha || !message || !author || !date) continue;
|
||||
|
||||
commits.push({
|
||||
sha: sha.trim(),
|
||||
message: message.trim(),
|
||||
author: author.trim(),
|
||||
date: date.trim(),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: commits,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
code: "PARSE_ERROR",
|
||||
message: `Failed to parse commit information: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
385
src/server/service/git/getDiff.ts
Normal file
385
src/server/service/git/getDiff.ts
Normal file
@@ -0,0 +1,385 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import parseGitDiff, {
|
||||
type AnyChunk,
|
||||
type AnyFileChange,
|
||||
} from "parse-git-diff";
|
||||
import type {
|
||||
GitComparisonResult,
|
||||
GitDiff,
|
||||
GitDiffFile,
|
||||
GitDiffHunk,
|
||||
GitDiffLine,
|
||||
GitResult,
|
||||
} from "./types";
|
||||
import { executeGitCommand, parseLines, stripAnsiColors } from "./utils";
|
||||
|
||||
/**
|
||||
* Convert parse-git-diff file change to GitDiffFile
|
||||
*/
|
||||
function convertToGitDiffFile(
|
||||
fileChange: AnyFileChange,
|
||||
fileStats: Map<string, { additions: number; deletions: number }>,
|
||||
): GitDiffFile {
|
||||
let filePath: string;
|
||||
let status: GitDiffFile["status"];
|
||||
let oldPath: string | undefined;
|
||||
|
||||
switch (fileChange.type) {
|
||||
case "AddedFile":
|
||||
filePath = fileChange.path;
|
||||
status = "added";
|
||||
break;
|
||||
case "DeletedFile":
|
||||
filePath = fileChange.path;
|
||||
status = "deleted";
|
||||
break;
|
||||
case "RenamedFile":
|
||||
filePath = fileChange.pathAfter;
|
||||
oldPath = fileChange.pathBefore;
|
||||
status = "renamed";
|
||||
break;
|
||||
case "ChangedFile":
|
||||
filePath = fileChange.path;
|
||||
status = "modified";
|
||||
break;
|
||||
default:
|
||||
// Fallback for any unknown types
|
||||
filePath = "";
|
||||
status = "modified";
|
||||
}
|
||||
|
||||
// Get stats from numstat
|
||||
const stats = fileStats.get(filePath) ||
|
||||
fileStats.get(oldPath || "") || { additions: 0, deletions: 0 };
|
||||
|
||||
return {
|
||||
filePath,
|
||||
status,
|
||||
additions: stats.additions,
|
||||
deletions: stats.deletions,
|
||||
oldPath,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert parse-git-diff chunk to GitDiffHunk
|
||||
*/
|
||||
function convertToGitDiffHunk(chunk: AnyChunk): GitDiffHunk {
|
||||
if (chunk.type !== "Chunk") {
|
||||
// For non-standard chunks, return empty hunk
|
||||
return {
|
||||
oldStart: 0,
|
||||
oldCount: 0,
|
||||
newStart: 0,
|
||||
newCount: 0,
|
||||
header: "",
|
||||
lines: [],
|
||||
};
|
||||
}
|
||||
|
||||
const lines: GitDiffLine[] = [];
|
||||
|
||||
for (const change of chunk.changes) {
|
||||
let line: GitDiffLine;
|
||||
|
||||
switch (change.type) {
|
||||
case "AddedLine":
|
||||
line = {
|
||||
type: "added",
|
||||
content: change.content,
|
||||
newLineNumber: change.lineAfter,
|
||||
};
|
||||
break;
|
||||
case "DeletedLine":
|
||||
line = {
|
||||
type: "deleted",
|
||||
content: change.content,
|
||||
oldLineNumber: change.lineBefore,
|
||||
};
|
||||
break;
|
||||
case "UnchangedLine":
|
||||
line = {
|
||||
type: "context",
|
||||
content: change.content,
|
||||
oldLineNumber: change.lineBefore,
|
||||
newLineNumber: change.lineAfter,
|
||||
};
|
||||
break;
|
||||
case "MessageLine":
|
||||
// This is likely a hunk header or context line
|
||||
line = {
|
||||
type: "context",
|
||||
content: change.content,
|
||||
};
|
||||
break;
|
||||
default:
|
||||
// Fallback for unknown line types
|
||||
line = {
|
||||
type: "context",
|
||||
content: "",
|
||||
};
|
||||
}
|
||||
|
||||
lines.push(line);
|
||||
}
|
||||
|
||||
return {
|
||||
oldStart: chunk.fromFileRange.start,
|
||||
oldCount: chunk.fromFileRange.lines,
|
||||
newStart: chunk.toFileRange.start,
|
||||
newCount: chunk.toFileRange.lines,
|
||||
header: `@@ -${chunk.fromFileRange.start},${chunk.fromFileRange.lines} +${chunk.toFileRange.start},${chunk.toFileRange.lines} @@${chunk.context ? ` ${chunk.context}` : ""}`,
|
||||
lines,
|
||||
};
|
||||
}
|
||||
|
||||
const extractRef = (refText: string) => {
|
||||
const [group, ref] = refText.split(":");
|
||||
if (group === undefined || ref === undefined) {
|
||||
if (refText === "HEAD") {
|
||||
return "HEAD";
|
||||
}
|
||||
|
||||
if (refText === "working") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
throw new Error(`Invalid ref text: ${refText}`);
|
||||
}
|
||||
|
||||
return ref;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get untracked files using git status
|
||||
*/
|
||||
async function getUntrackedFiles(cwd: string): Promise<GitResult<string[]>> {
|
||||
const statusResult = await executeGitCommand(
|
||||
["status", "--untracked-files=all", "--short"],
|
||||
cwd,
|
||||
);
|
||||
|
||||
console.log("debug statusResult stdout", statusResult);
|
||||
|
||||
if (!statusResult.success) {
|
||||
return statusResult;
|
||||
}
|
||||
|
||||
try {
|
||||
const untrackedFiles = parseLines(statusResult.data)
|
||||
.map((line) => stripAnsiColors(line)) // Remove ANSI color codes first
|
||||
.filter((line) => line.startsWith("??"))
|
||||
.map((line) => line.slice(3));
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: untrackedFiles,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
code: "PARSE_ERROR",
|
||||
message: `Failed to parse status output: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create artificial diff for an untracked file (all lines as additions)
|
||||
*/
|
||||
async function createUntrackedFileDiff(
|
||||
cwd: string,
|
||||
filePath: string,
|
||||
): Promise<GitDiff | null> {
|
||||
try {
|
||||
const fullPath = resolve(cwd, filePath);
|
||||
const content = await readFile(fullPath, "utf8");
|
||||
const lines = content.split("\n");
|
||||
|
||||
const diffLines: GitDiffLine[] = lines.map((line, index) => ({
|
||||
type: "added" as const,
|
||||
content: line,
|
||||
newLineNumber: index + 1,
|
||||
}));
|
||||
|
||||
const file: GitDiffFile = {
|
||||
filePath,
|
||||
status: "added",
|
||||
additions: lines.length,
|
||||
deletions: 0,
|
||||
};
|
||||
|
||||
const hunk: GitDiffHunk = {
|
||||
oldStart: 0,
|
||||
oldCount: 0,
|
||||
newStart: 1,
|
||||
newCount: lines.length,
|
||||
header: `@@ -0,0 +1,${lines.length} @@`,
|
||||
lines: diffLines,
|
||||
};
|
||||
|
||||
return {
|
||||
file,
|
||||
hunks: [hunk],
|
||||
};
|
||||
} catch (error) {
|
||||
// Skip files that can't be read (e.g., binary files, permission errors)
|
||||
console.warn(`Failed to read untracked file ${filePath}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Git diff between two references (branches, commits, tags)
|
||||
*/
|
||||
export const getDiff = async (
|
||||
cwd: string,
|
||||
fromRefText: string,
|
||||
toRefText: string,
|
||||
): Promise<GitResult<GitComparisonResult>> => {
|
||||
const fromRef = extractRef(fromRefText);
|
||||
const toRef = extractRef(toRefText);
|
||||
|
||||
if (fromRef === toRef) {
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
diffs: [],
|
||||
files: [],
|
||||
summary: {
|
||||
totalFiles: 0,
|
||||
totalAdditions: 0,
|
||||
totalDeletions: 0,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (fromRef === undefined) {
|
||||
throw new Error(`Invalid fromRef: ${fromRefText}`);
|
||||
}
|
||||
|
||||
const commandArgs = toRef === undefined ? [fromRef] : [fromRef, toRef];
|
||||
|
||||
// Get diff with numstat for file statistics
|
||||
const numstatResult = await executeGitCommand(
|
||||
["diff", "--numstat", ...commandArgs],
|
||||
cwd,
|
||||
);
|
||||
|
||||
if (!numstatResult.success) {
|
||||
return numstatResult;
|
||||
}
|
||||
|
||||
// Get diff with full content
|
||||
const diffResult = await executeGitCommand(
|
||||
["diff", "--unified=5", ...commandArgs],
|
||||
cwd,
|
||||
);
|
||||
|
||||
if (!diffResult.success) {
|
||||
return diffResult;
|
||||
}
|
||||
|
||||
try {
|
||||
// Parse numstat output to get file statistics
|
||||
const fileStats = new Map<
|
||||
string,
|
||||
{ additions: number; deletions: number }
|
||||
>();
|
||||
const numstatLines = parseLines(numstatResult.data);
|
||||
|
||||
for (const line of numstatLines) {
|
||||
const parts = line.split("\t");
|
||||
if (parts.length >= 3 && parts[0] && parts[1] && parts[2]) {
|
||||
const additions = parts[0] === "-" ? 0 : parseInt(parts[0], 10);
|
||||
const deletions = parts[1] === "-" ? 0 : parseInt(parts[1], 10);
|
||||
const filePath = parts[2];
|
||||
fileStats.set(filePath, { additions, deletions });
|
||||
}
|
||||
}
|
||||
|
||||
// Parse diff output using parse-git-diff
|
||||
const parsedDiff = parseGitDiff(diffResult.data);
|
||||
|
||||
const files: GitDiffFile[] = [];
|
||||
const diffs: GitDiff[] = [];
|
||||
let totalAdditions = 0;
|
||||
let totalDeletions = 0;
|
||||
|
||||
for (const fileChange of parsedDiff.files) {
|
||||
// Convert to GitDiffFile format
|
||||
const file = convertToGitDiffFile(fileChange, fileStats);
|
||||
files.push(file);
|
||||
|
||||
// Convert chunks to hunks
|
||||
const hunks: GitDiffHunk[] = [];
|
||||
for (const chunk of fileChange.chunks) {
|
||||
const hunk = convertToGitDiffHunk(chunk);
|
||||
hunks.push(hunk);
|
||||
}
|
||||
|
||||
diffs.push({
|
||||
file,
|
||||
hunks,
|
||||
});
|
||||
|
||||
totalAdditions += file.additions;
|
||||
totalDeletions += file.deletions;
|
||||
}
|
||||
|
||||
// Include untracked files when comparing to working directory
|
||||
if (toRef === undefined) {
|
||||
const untrackedResult = await getUntrackedFiles(cwd);
|
||||
console.log("debug untrackedResult", untrackedResult);
|
||||
if (untrackedResult.success) {
|
||||
for (const untrackedFile of untrackedResult.data) {
|
||||
const untrackedDiff = await createUntrackedFileDiff(
|
||||
cwd,
|
||||
untrackedFile,
|
||||
);
|
||||
if (untrackedDiff) {
|
||||
files.push(untrackedDiff.file);
|
||||
diffs.push(untrackedDiff);
|
||||
totalAdditions += untrackedDiff.file.additions;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
files,
|
||||
diffs,
|
||||
summary: {
|
||||
totalFiles: files.length,
|
||||
totalAdditions,
|
||||
totalDeletions,
|
||||
},
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
code: "PARSE_ERROR",
|
||||
message: `Failed to parse diff: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Compare between two branches (shorthand for getDiff)
|
||||
*/
|
||||
export async function compareBranches(
|
||||
cwd: string,
|
||||
baseBranch: string,
|
||||
targetBranch: string,
|
||||
): Promise<GitResult<GitComparisonResult>> {
|
||||
return getDiff(cwd, baseBranch, targetBranch);
|
||||
}
|
||||
172
src/server/service/git/getStatus.ts
Normal file
172
src/server/service/git/getStatus.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import type { GitDiffFile, GitResult, GitStatus } from "./types";
|
||||
import {
|
||||
executeGitCommand,
|
||||
getFileStatus,
|
||||
parseLines,
|
||||
parseStatusLine,
|
||||
} from "./utils";
|
||||
|
||||
/**
|
||||
* Get git status information including staged, unstaged, and untracked files
|
||||
*/
|
||||
export async function getStatus(cwd: string): Promise<GitResult<GitStatus>> {
|
||||
// Get porcelain status for consistent parsing
|
||||
const statusResult = await executeGitCommand(
|
||||
["status", "--porcelain=v1", "-b"],
|
||||
cwd,
|
||||
);
|
||||
|
||||
if (!statusResult.success) {
|
||||
return statusResult as GitResult<GitStatus>;
|
||||
}
|
||||
|
||||
try {
|
||||
const lines = parseLines(statusResult.data);
|
||||
const staged: GitDiffFile[] = [];
|
||||
const unstaged: GitDiffFile[] = [];
|
||||
const untracked: string[] = [];
|
||||
const conflicted: string[] = [];
|
||||
|
||||
let branch = "HEAD";
|
||||
let ahead = 0;
|
||||
let behind = 0;
|
||||
|
||||
for (const line of lines) {
|
||||
// Parse branch line
|
||||
if (line.startsWith("##")) {
|
||||
const branchMatch = line.match(/^##\s+(.+?)(?:\.\.\.|$)/);
|
||||
if (branchMatch?.[1]) {
|
||||
branch = branchMatch[1];
|
||||
}
|
||||
|
||||
const aheadMatch = line.match(/ahead (\d+)/);
|
||||
const behindMatch = line.match(/behind (\d+)/);
|
||||
if (aheadMatch?.[1]) ahead = parseInt(aheadMatch[1], 10);
|
||||
if (behindMatch?.[1]) behind = parseInt(behindMatch[1], 10);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Parse file status lines
|
||||
const { status, filePath, oldPath } = parseStatusLine(line);
|
||||
const indexStatus = status[0]; // Staged changes
|
||||
const workTreeStatus = status[1]; // Unstaged changes
|
||||
|
||||
// Handle conflicts (both index and work tree have changes)
|
||||
if (
|
||||
indexStatus === "U" ||
|
||||
workTreeStatus === "U" ||
|
||||
(indexStatus !== " " &&
|
||||
indexStatus !== "?" &&
|
||||
workTreeStatus !== " " &&
|
||||
workTreeStatus !== "?")
|
||||
) {
|
||||
conflicted.push(filePath);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle staged changes (index status)
|
||||
if (indexStatus !== " " && indexStatus !== "?") {
|
||||
staged.push({
|
||||
filePath,
|
||||
status: getFileStatus(`${indexStatus} `),
|
||||
additions: 0, // We don't have line counts from porcelain status
|
||||
deletions: 0,
|
||||
oldPath,
|
||||
});
|
||||
}
|
||||
|
||||
// Handle unstaged changes (work tree status)
|
||||
if (workTreeStatus !== " " && workTreeStatus !== "?") {
|
||||
if (workTreeStatus === "?") {
|
||||
untracked.push(filePath);
|
||||
} else {
|
||||
unstaged.push({
|
||||
filePath,
|
||||
status: getFileStatus(` ${workTreeStatus}`),
|
||||
additions: 0,
|
||||
deletions: 0,
|
||||
oldPath,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Handle untracked files
|
||||
if (status === "??") {
|
||||
untracked.push(filePath);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
branch,
|
||||
ahead,
|
||||
behind,
|
||||
staged,
|
||||
unstaged,
|
||||
untracked,
|
||||
conflicted,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
code: "PARSE_ERROR",
|
||||
message: `Failed to parse git status: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get uncommitted changes (both staged and unstaged)
|
||||
*/
|
||||
export async function getUncommittedChanges(
|
||||
cwd: string,
|
||||
): Promise<GitResult<GitDiffFile[]>> {
|
||||
const statusResult = await getStatus(cwd);
|
||||
|
||||
if (!statusResult.success) {
|
||||
return statusResult as GitResult<GitDiffFile[]>;
|
||||
}
|
||||
|
||||
const { staged, unstaged } = statusResult.data;
|
||||
const allChanges = [...staged, ...unstaged];
|
||||
|
||||
// Remove duplicates (files that are both staged and unstaged)
|
||||
const uniqueChanges = allChanges.reduce((acc: GitDiffFile[], change) => {
|
||||
const existing = acc.find((c) => c.filePath === change.filePath);
|
||||
if (!existing) {
|
||||
acc.push(change);
|
||||
}
|
||||
return acc;
|
||||
}, [] as GitDiffFile[]);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: uniqueChanges,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the working directory is clean (no uncommitted changes)
|
||||
*/
|
||||
export async function isWorkingDirectoryClean(
|
||||
cwd: string,
|
||||
): Promise<GitResult<boolean>> {
|
||||
const statusResult = await getStatus(cwd);
|
||||
|
||||
if (!statusResult.success) {
|
||||
return statusResult as GitResult<boolean>;
|
||||
}
|
||||
|
||||
const { staged, unstaged, untracked } = statusResult.data;
|
||||
const isClean =
|
||||
staged.length === 0 && unstaged.length === 0 && untracked.length === 0;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: isClean,
|
||||
};
|
||||
}
|
||||
32
src/server/service/git/index.ts
Normal file
32
src/server/service/git/index.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
// Git service utilities for claude-code-viewer
|
||||
// Provides comprehensive Git operations including branch management, diff generation, and status checking
|
||||
|
||||
export * from "./getBranches";
|
||||
// Re-export main functions for convenience
|
||||
export { branchExists, getBranches, getCurrentBranch } from "./getBranches";
|
||||
export * from "./getCommits";
|
||||
export { getCommits } from "./getCommits";
|
||||
export * from "./getDiff";
|
||||
export { compareBranches, getDiff } from "./getDiff";
|
||||
export * from "./getStatus";
|
||||
export {
|
||||
getStatus,
|
||||
getUncommittedChanges,
|
||||
isWorkingDirectoryClean,
|
||||
} from "./getStatus";
|
||||
// Types re-export for convenience
|
||||
export type {
|
||||
GitBranch,
|
||||
GitCommit,
|
||||
GitComparisonResult,
|
||||
GitDiff,
|
||||
GitDiffFile,
|
||||
GitDiffHunk,
|
||||
GitDiffLine,
|
||||
GitError,
|
||||
GitResult,
|
||||
GitStatus,
|
||||
} from "./types";
|
||||
export * from "./types";
|
||||
export * from "./utils";
|
||||
export { executeGitCommand, isGitRepository } from "./utils";
|
||||
85
src/server/service/git/types.ts
Normal file
85
src/server/service/git/types.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
export type GitBranch = {
|
||||
name: string;
|
||||
current: boolean;
|
||||
remote?: string;
|
||||
commit: string;
|
||||
ahead?: number;
|
||||
behind?: number;
|
||||
};
|
||||
|
||||
export type GitCommit = {
|
||||
sha: string;
|
||||
message: string;
|
||||
author: string;
|
||||
date: string;
|
||||
};
|
||||
|
||||
export type GitDiffFile = {
|
||||
filePath: string;
|
||||
status: "added" | "modified" | "deleted" | "renamed" | "copied";
|
||||
additions: number;
|
||||
deletions: number;
|
||||
oldPath?: string; // For renamed files
|
||||
};
|
||||
|
||||
export type GitDiffHunk = {
|
||||
oldStart: number;
|
||||
oldCount: number;
|
||||
newStart: number;
|
||||
newCount: number;
|
||||
header: string;
|
||||
lines: GitDiffLine[];
|
||||
};
|
||||
|
||||
export type GitDiffLine = {
|
||||
type: "context" | "added" | "deleted";
|
||||
content: string;
|
||||
oldLineNumber?: number;
|
||||
newLineNumber?: number;
|
||||
};
|
||||
|
||||
export type GitDiff = {
|
||||
file: GitDiffFile;
|
||||
hunks: GitDiffHunk[];
|
||||
};
|
||||
|
||||
export type GitComparisonResult = {
|
||||
files: GitDiffFile[];
|
||||
diffs: GitDiff[];
|
||||
summary: {
|
||||
totalFiles: number;
|
||||
totalAdditions: number;
|
||||
totalDeletions: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type GitStatus = {
|
||||
branch: string;
|
||||
ahead: number;
|
||||
behind: number;
|
||||
staged: GitDiffFile[];
|
||||
unstaged: GitDiffFile[];
|
||||
untracked: string[];
|
||||
conflicted: string[];
|
||||
};
|
||||
|
||||
export type GitError = {
|
||||
code:
|
||||
| "NOT_A_REPOSITORY"
|
||||
| "BRANCH_NOT_FOUND"
|
||||
| "COMMAND_FAILED"
|
||||
| "PARSE_ERROR";
|
||||
message: string;
|
||||
command?: string;
|
||||
stderr?: string;
|
||||
};
|
||||
|
||||
export type GitResult<T> =
|
||||
| {
|
||||
success: true;
|
||||
data: T;
|
||||
}
|
||||
| {
|
||||
success: false;
|
||||
error: GitError;
|
||||
};
|
||||
151
src/server/service/git/utils.ts
Normal file
151
src/server/service/git/utils.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import type { GitError, GitResult } from "./types";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
/**
|
||||
* Execute a git command in the specified directory
|
||||
*/
|
||||
export async function executeGitCommand(
|
||||
args: string[],
|
||||
cwd: string,
|
||||
): Promise<GitResult<string>> {
|
||||
try {
|
||||
// Check if the directory exists and contains a git repository
|
||||
if (!existsSync(cwd)) {
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
code: "NOT_A_REPOSITORY",
|
||||
message: `Directory does not exist: ${cwd}`,
|
||||
command: `git ${args.join(" ")}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (!existsSync(resolve(cwd, ".git"))) {
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
code: "NOT_A_REPOSITORY",
|
||||
message: `Not a git repository: ${cwd}`,
|
||||
command: `git ${args.join(" ")}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const { stdout } = await execFileAsync("git", args, {
|
||||
cwd,
|
||||
maxBuffer: 10 * 1024 * 1024, // 10MB buffer for large diffs
|
||||
timeout: 30000, // 30 second timeout
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: stdout,
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
const err = error as { code?: string; stderr?: string; message?: string };
|
||||
|
||||
let errorCode: GitError["code"] = "COMMAND_FAILED";
|
||||
let errorMessage = err.message || "Unknown git command error";
|
||||
|
||||
if (err.stderr) {
|
||||
if (err.stderr.includes("not a git repository")) {
|
||||
errorCode = "NOT_A_REPOSITORY";
|
||||
errorMessage = "Not a git repository";
|
||||
} else if (err.stderr.includes("unknown revision")) {
|
||||
errorCode = "BRANCH_NOT_FOUND";
|
||||
errorMessage = "Branch or commit not found";
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
code: errorCode,
|
||||
message: errorMessage,
|
||||
command: `git ${args.join(" ")}`,
|
||||
stderr: err.stderr,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a directory is a git repository
|
||||
*/
|
||||
export function isGitRepository(cwd: string): boolean {
|
||||
return existsSync(cwd) && existsSync(resolve(cwd, ".git"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove ANSI color codes from a string
|
||||
*/
|
||||
export function stripAnsiColors(text: string): string {
|
||||
// ANSI escape sequence pattern: \x1B[...m
|
||||
// biome-ignore lint/suspicious/noControlCharactersInRegex: this is a valid regex
|
||||
return text.replace(/\x1B\[[0-9;]*m/g, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely parse git command output that might be empty
|
||||
*/
|
||||
export function parseLines(output: string): string[] {
|
||||
return output
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter((line) => line.trim() !== "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse git status porcelain output
|
||||
*/
|
||||
export function parseStatusLine(line: string): {
|
||||
status: string;
|
||||
filePath: string;
|
||||
oldPath?: string;
|
||||
} {
|
||||
const status = line.slice(0, 2);
|
||||
const filePath = line.slice(3);
|
||||
|
||||
// Handle renamed files (R old -> new)
|
||||
if (status.startsWith("R")) {
|
||||
const parts = filePath.split(" -> ");
|
||||
return {
|
||||
status,
|
||||
filePath: parts[1] || filePath,
|
||||
oldPath: parts[0],
|
||||
};
|
||||
}
|
||||
|
||||
return { status, filePath };
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert git status code to readable status
|
||||
*/
|
||||
export function getFileStatus(
|
||||
statusCode: string,
|
||||
): "added" | "modified" | "deleted" | "renamed" | "copied" {
|
||||
const firstChar = statusCode[0];
|
||||
|
||||
switch (firstChar) {
|
||||
case "A":
|
||||
return "added";
|
||||
case "M":
|
||||
return "modified";
|
||||
case "D":
|
||||
return "deleted";
|
||||
case "R":
|
||||
return "renamed";
|
||||
case "C":
|
||||
return "copied";
|
||||
default:
|
||||
return "modified";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user