mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-27 15:17:38 +00:00
feat: Add image upload functionality with camera/gallery picker (#140)
Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
parent
1d9112b9d3
commit
8d85c26a84
14 changed files with 1740 additions and 19 deletions
|
|
@ -1,8 +1,9 @@
|
|||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(pnpm test:*)"
|
||||
"Bash(pnpm test:*)",
|
||||
"Bash(rg:*)"
|
||||
],
|
||||
"deny": []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
21
.github/workflows/web-ci.yml
vendored
21
.github/workflows/web-ci.yml
vendored
|
|
@ -123,13 +123,24 @@ jobs:
|
|||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Run tests
|
||||
run: pnpm run test:ci
|
||||
- name: Run client tests
|
||||
run: pnpm run test:client:coverage
|
||||
|
||||
- name: Upload coverage
|
||||
- name: Run server tests
|
||||
run: pnpm run test:server:coverage
|
||||
|
||||
- name: Upload client coverage
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: coverage-report
|
||||
path: web/coverage/
|
||||
name: client-coverage-report
|
||||
path: web/coverage/client/
|
||||
retention-days: 7
|
||||
|
||||
- name: Upload server coverage
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: server-coverage-report
|
||||
path: web/coverage/server/
|
||||
retention-days: 7
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -105,6 +105,12 @@ web/vibetunnel
|
|||
/server/vibetunnel-server
|
||||
server/vibetunnel-fwd
|
||||
/.build-tools
|
||||
linux/vibetunnel
|
||||
*.o
|
||||
|
||||
# Rust build artifacts
|
||||
tty-fwd/target/
|
||||
tty-fwd/Cargo.lock
|
||||
|
||||
# Bun prebuilt executables (should be built during build process)
|
||||
mac/Resources/BunPrebuilts/
|
||||
|
|
|
|||
3
web/.gitignore
vendored
3
web/.gitignore
vendored
|
|
@ -33,6 +33,9 @@ jspm_packages/
|
|||
# npm lock file (using pnpm instead)
|
||||
package-lock.json
|
||||
|
||||
# yarn lock file (using pnpm instead)
|
||||
yarn.lock
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@
|
|||
"@codemirror/state": "^6.4.1",
|
||||
"@codemirror/theme-one-dark": "^6.1.2",
|
||||
"@codemirror/view": "^6.28.0",
|
||||
"@types/multer": "^1.4.13",
|
||||
"@xterm/headless": "^5.5.0",
|
||||
"authenticate-pam": "^1.0.5",
|
||||
"chalk": "^4.1.2",
|
||||
|
|
@ -66,6 +67,7 @@
|
|||
"lit": "^3.3.0",
|
||||
"mime-types": "^3.0.1",
|
||||
"monaco-editor": "^0.52.2",
|
||||
"multer": "^2.0.1",
|
||||
"node-pty": "github:microsoft/node-pty#v1.1.0-beta34",
|
||||
"postject": "^1.0.0-alpha.6",
|
||||
"signal-exit": "^4.1.0",
|
||||
|
|
|
|||
|
|
@ -38,6 +38,9 @@ importers:
|
|||
'@codemirror/view':
|
||||
specifier: ^6.28.0
|
||||
version: 6.37.2
|
||||
'@types/multer':
|
||||
specifier: ^1.4.13
|
||||
version: 1.4.13
|
||||
'@xterm/headless':
|
||||
specifier: ^5.5.0
|
||||
version: 5.5.0
|
||||
|
|
@ -62,6 +65,9 @@ importers:
|
|||
monaco-editor:
|
||||
specifier: ^0.52.2
|
||||
version: 0.52.2
|
||||
multer:
|
||||
specifier: ^2.0.1
|
||||
version: 2.0.1
|
||||
node-pty:
|
||||
specifier: github:microsoft/node-pty#v1.1.0-beta34
|
||||
version: https://codeload.github.com/microsoft/node-pty/tar.gz/d738123f1faf7287513b0df8b9e327be54702e94
|
||||
|
|
@ -86,7 +92,7 @@ importers:
|
|||
version: 4.0.0
|
||||
'@playwright/test':
|
||||
specifier: ^1.53.1
|
||||
version: 1.53.1
|
||||
version: 1.53.2
|
||||
'@prettier/plugin-oxc':
|
||||
specifier: ^0.0.4
|
||||
version: 0.0.4
|
||||
|
|
@ -672,8 +678,8 @@ packages:
|
|||
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
'@playwright/test@1.53.1':
|
||||
resolution: {integrity: sha512-Z4c23LHV0muZ8hfv4jw6HngPJkbbtZxTkxPNIg7cJcTc9C28N/p2q7g3JZS2SiKBBHJ3uM1dgDye66bB7LEk5w==}
|
||||
'@playwright/test@1.53.2':
|
||||
resolution: {integrity: sha512-tEB2U5z74ebBeyfGNZ3Jfg29AnW+5HlWhvHtb/Mqco9pFdZU1ZLNdVb2UtB5CvmiilNr2ZfVH/qMmAROG/XTzw==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
|
|
@ -892,6 +898,9 @@ packages:
|
|||
'@types/ms@2.1.0':
|
||||
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
|
||||
|
||||
'@types/multer@1.4.13':
|
||||
resolution: {integrity: sha512-bhhdtPw7JqCiEfC9Jimx5LqX9BDIPJEh2q/fQ4bqbBPtyEZYr3cvF22NwG0DmPZNYA0CAf2CnqDB4KIGGpJcaw==}
|
||||
|
||||
'@types/node@20.19.1':
|
||||
resolution: {integrity: sha512-jJD50LtlD2dodAEO653i3YF04NWak6jN3ky+Ri3Em3mGR39/glWiboM/IePaRbgwSfqM1TpGXfAg8ohn/4dTgA==}
|
||||
|
||||
|
|
@ -1066,6 +1075,9 @@ packages:
|
|||
resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==}
|
||||
engines: {node: '>= 8'}
|
||||
|
||||
append-field@1.0.0:
|
||||
resolution: {integrity: sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==}
|
||||
|
||||
arg@5.0.2:
|
||||
resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==}
|
||||
|
||||
|
|
@ -1189,6 +1201,13 @@ packages:
|
|||
buffer-equal-constant-time@1.0.1:
|
||||
resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==}
|
||||
|
||||
buffer-from@1.1.2:
|
||||
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
|
||||
|
||||
busboy@1.6.0:
|
||||
resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==}
|
||||
engines: {node: '>=10.16.0'}
|
||||
|
||||
bytes@3.1.2:
|
||||
resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
|
@ -1327,6 +1346,10 @@ packages:
|
|||
component-emitter@1.3.1:
|
||||
resolution: {integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==}
|
||||
|
||||
concat-stream@2.0.0:
|
||||
resolution: {integrity: sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==}
|
||||
engines: {'0': node >= 6.0}
|
||||
|
||||
concurrently@9.2.0:
|
||||
resolution: {integrity: sha512-IsB/fiXTupmagMW4MNp2lx2cdSN2FfZq78vF90LBB+zZHArbIQZjQtzXCiXnvTxCZSvXanTqFLWBjw2UkLx1SQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
|
@ -2171,6 +2194,10 @@ packages:
|
|||
mitt@3.0.1:
|
||||
resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==}
|
||||
|
||||
mkdirp@0.5.6:
|
||||
resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==}
|
||||
hasBin: true
|
||||
|
||||
mkdirp@1.0.4:
|
||||
resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==}
|
||||
engines: {node: '>=10'}
|
||||
|
|
@ -2189,6 +2216,10 @@ packages:
|
|||
ms@2.1.3:
|
||||
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
||||
|
||||
multer@2.0.1:
|
||||
resolution: {integrity: sha512-Ug8bXeTIUlxurg8xLTEskKShvcKDZALo1THEX5E41pYCD2sCVub5/kIRIGqWNoqV6szyLyQKV6mD4QUrWE5GCQ==}
|
||||
engines: {node: '>= 10.16.0'}
|
||||
|
||||
mz@2.7.0:
|
||||
resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==}
|
||||
|
||||
|
|
@ -2394,13 +2425,13 @@ packages:
|
|||
resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
playwright-core@1.53.1:
|
||||
resolution: {integrity: sha512-Z46Oq7tLAyT0lGoFx4DOuB1IA9D1TPj0QkYxpPVUnGDqHHvDpCftu1J2hM2PiWsNMoZh8+LQaarAWcDfPBc6zg==}
|
||||
playwright-core@1.53.2:
|
||||
resolution: {integrity: sha512-ox/OytMy+2w1jcYEYlOo1Hhp8hZkLCximMTUTMBXjGUA1KoFfiSZ+DU+3a739jsPY0yoKH2TFy9S2fsJas8yAw==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
playwright@1.53.1:
|
||||
resolution: {integrity: sha512-LJ13YLr/ocweuwxyGf1XNFWIU4M2zUSo149Qbp+A4cpwDjsxRPj7k6H25LBrEHiEwxvRbD8HdwvQmRMSvquhYw==}
|
||||
playwright@1.53.2:
|
||||
resolution: {integrity: sha512-6K/qQxVFuVQhRQhFsVZ9fGeatxirtrpPgxzBYWyZLEXJzqYwuL4fuNmfOfD5et1tJE4GScKyPNeLhZeRwuTU3A==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
|
|
@ -2511,6 +2542,10 @@ packages:
|
|||
read-cache@1.0.0:
|
||||
resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==}
|
||||
|
||||
readable-stream@3.6.2:
|
||||
resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
readdirp@3.6.0:
|
||||
resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
|
||||
engines: {node: '>=8.10.0'}
|
||||
|
|
@ -2699,6 +2734,10 @@ packages:
|
|||
std-env@3.9.0:
|
||||
resolution: {integrity: sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==}
|
||||
|
||||
streamsearch@1.1.0:
|
||||
resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
|
||||
streamx@2.22.1:
|
||||
resolution: {integrity: sha512-znKXEBxfatz2GBNK02kRnCXjV+AA4kjZIUxeWSr3UGirZMJfTE9uiwKHobnbgxWyL/JWro8tTq+vOqAK1/qbSA==}
|
||||
|
||||
|
|
@ -2722,6 +2761,9 @@ packages:
|
|||
resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
string_decoder@1.3.0:
|
||||
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
|
||||
|
||||
strip-ansi@5.2.0:
|
||||
resolution: {integrity: sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==}
|
||||
engines: {node: '>=6'}
|
||||
|
|
@ -2858,6 +2900,9 @@ packages:
|
|||
typed-query-selector@2.12.0:
|
||||
resolution: {integrity: sha512-SbklCd1F0EiZOyPiW192rrHZzZ5sBijB6xM+cpmrwDqObvdtunOHHIk9fCGsoK5JVIYXoyEp4iEdE3upFH3PAg==}
|
||||
|
||||
typedarray@0.0.6:
|
||||
resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==}
|
||||
|
||||
typescript@5.8.3:
|
||||
resolution: {integrity: sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==}
|
||||
engines: {node: '>=14.17'}
|
||||
|
|
@ -3046,6 +3091,10 @@ packages:
|
|||
utf-8-validate:
|
||||
optional: true
|
||||
|
||||
xtend@4.0.2:
|
||||
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
|
||||
engines: {node: '>=0.4'}
|
||||
|
||||
y18n@4.0.3:
|
||||
resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==}
|
||||
|
||||
|
|
@ -3539,9 +3588,9 @@ snapshots:
|
|||
'@pkgjs/parseargs@0.11.0':
|
||||
optional: true
|
||||
|
||||
'@playwright/test@1.53.1':
|
||||
'@playwright/test@1.53.2':
|
||||
dependencies:
|
||||
playwright: 1.53.1
|
||||
playwright: 1.53.2
|
||||
|
||||
'@polka/url@1.0.0-next.29': {}
|
||||
|
||||
|
|
@ -3749,6 +3798,10 @@ snapshots:
|
|||
|
||||
'@types/ms@2.1.0': {}
|
||||
|
||||
'@types/multer@1.4.13':
|
||||
dependencies:
|
||||
'@types/express': 4.17.23
|
||||
|
||||
'@types/node@20.19.1':
|
||||
dependencies:
|
||||
undici-types: 6.21.0
|
||||
|
|
@ -4010,6 +4063,8 @@ snapshots:
|
|||
normalize-path: 3.0.0
|
||||
picomatch: 2.3.1
|
||||
|
||||
append-field@1.0.0: {}
|
||||
|
||||
arg@5.0.2: {}
|
||||
|
||||
argparse@2.0.1: {}
|
||||
|
|
@ -4134,6 +4189,12 @@ snapshots:
|
|||
|
||||
buffer-equal-constant-time@1.0.1: {}
|
||||
|
||||
buffer-from@1.1.2: {}
|
||||
|
||||
busboy@1.6.0:
|
||||
dependencies:
|
||||
streamsearch: 1.1.0
|
||||
|
||||
bytes@3.1.2: {}
|
||||
|
||||
cac@6.7.14: {}
|
||||
|
|
@ -4274,6 +4335,13 @@ snapshots:
|
|||
|
||||
component-emitter@1.3.1: {}
|
||||
|
||||
concat-stream@2.0.0:
|
||||
dependencies:
|
||||
buffer-from: 1.1.2
|
||||
inherits: 2.0.4
|
||||
readable-stream: 3.6.2
|
||||
typedarray: 0.0.6
|
||||
|
||||
concurrently@9.2.0:
|
||||
dependencies:
|
||||
chalk: 4.1.2
|
||||
|
|
@ -5170,6 +5238,10 @@ snapshots:
|
|||
|
||||
mitt@3.0.1: {}
|
||||
|
||||
mkdirp@0.5.6:
|
||||
dependencies:
|
||||
minimist: 1.2.8
|
||||
|
||||
mkdirp@1.0.4: {}
|
||||
|
||||
monaco-editor@0.52.2: {}
|
||||
|
|
@ -5180,6 +5252,16 @@ snapshots:
|
|||
|
||||
ms@2.1.3: {}
|
||||
|
||||
multer@2.0.1:
|
||||
dependencies:
|
||||
append-field: 1.0.0
|
||||
busboy: 1.6.0
|
||||
concat-stream: 2.0.0
|
||||
mkdirp: 0.5.6
|
||||
object-assign: 4.1.1
|
||||
type-is: 1.6.18
|
||||
xtend: 4.0.2
|
||||
|
||||
mz@2.7.0:
|
||||
dependencies:
|
||||
any-promise: 1.3.0
|
||||
|
|
@ -5362,11 +5444,11 @@ snapshots:
|
|||
|
||||
pirates@4.0.7: {}
|
||||
|
||||
playwright-core@1.53.1: {}
|
||||
playwright-core@1.53.2: {}
|
||||
|
||||
playwright@1.53.1:
|
||||
playwright@1.53.2:
|
||||
dependencies:
|
||||
playwright-core: 1.53.1
|
||||
playwright-core: 1.53.2
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.2
|
||||
|
||||
|
|
@ -5500,6 +5582,12 @@ snapshots:
|
|||
dependencies:
|
||||
pify: 2.3.0
|
||||
|
||||
readable-stream@3.6.2:
|
||||
dependencies:
|
||||
inherits: 2.0.4
|
||||
string_decoder: 1.3.0
|
||||
util-deprecate: 1.0.2
|
||||
|
||||
readdirp@3.6.0:
|
||||
dependencies:
|
||||
picomatch: 2.3.1
|
||||
|
|
@ -5716,6 +5804,8 @@ snapshots:
|
|||
|
||||
std-env@3.9.0: {}
|
||||
|
||||
streamsearch@1.1.0: {}
|
||||
|
||||
streamx@2.22.1:
|
||||
dependencies:
|
||||
fast-fifo: 1.3.2
|
||||
|
|
@ -5749,6 +5839,10 @@ snapshots:
|
|||
get-east-asian-width: 1.3.0
|
||||
strip-ansi: 7.1.0
|
||||
|
||||
string_decoder@1.3.0:
|
||||
dependencies:
|
||||
safe-buffer: 5.2.1
|
||||
|
||||
strip-ansi@5.2.0:
|
||||
dependencies:
|
||||
ansi-regex: 4.1.1
|
||||
|
|
@ -5918,6 +6012,8 @@ snapshots:
|
|||
|
||||
typed-query-selector@2.12.0: {}
|
||||
|
||||
typedarray@0.0.6: {}
|
||||
|
||||
typescript@5.8.3: {}
|
||||
|
||||
undici-types@6.21.0: {}
|
||||
|
|
@ -6084,6 +6180,8 @@ snapshots:
|
|||
|
||||
ws@8.18.2: {}
|
||||
|
||||
xtend@4.0.2: {}
|
||||
|
||||
y18n@4.0.3: {}
|
||||
|
||||
y18n@5.0.8: {}
|
||||
|
|
|
|||
257
web/src/client/components/file-picker.test.ts
Normal file
257
web/src/client/components/file-picker.test.ts
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
/**
|
||||
* Unit tests for FilePicker component
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import './file-picker.js';
|
||||
import type { FilePicker } from './file-picker.js';
|
||||
|
||||
// Mock auth client
|
||||
vi.mock('../services/auth-client.js', () => ({
|
||||
authClient: {
|
||||
getAuthHeader: () => ({ Authorization: 'Bearer test-token' }),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock logger
|
||||
vi.mock('../utils/logger.js', () => ({
|
||||
createLogger: () => ({
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('FilePicker Component', () => {
|
||||
let element: FilePicker;
|
||||
let container: HTMLElement;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
container.innerHTML = '<file-picker></file-picker>';
|
||||
element = container.querySelector('file-picker') as FilePicker;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it('should render when visible', async () => {
|
||||
element.visible = true;
|
||||
await element.updateComplete;
|
||||
|
||||
const modal = element.querySelector('.fixed');
|
||||
expect(modal).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should not render when not visible', async () => {
|
||||
element.visible = false;
|
||||
await element.updateComplete;
|
||||
|
||||
const modal = element.querySelector('.fixed');
|
||||
expect(modal).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should show upload progress when uploading', async () => {
|
||||
element.visible = true;
|
||||
element['uploading'] = true;
|
||||
element['uploadProgress'] = 50;
|
||||
await element.updateComplete;
|
||||
|
||||
const progressText = element.querySelector('span');
|
||||
expect(progressText?.textContent).toContain('Uploading...');
|
||||
|
||||
const progressBar = element.querySelector('.bg-blue-500');
|
||||
expect(progressBar).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should show file selection button when not uploading', async () => {
|
||||
element.visible = true;
|
||||
element['uploading'] = false;
|
||||
await element.updateComplete;
|
||||
|
||||
const buttons = element.querySelectorAll('button');
|
||||
const fileButton = Array.from(buttons).find((btn) => btn.textContent?.includes('Choose File'));
|
||||
expect(fileButton).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should emit file-cancel event when cancel button is clicked', async () => {
|
||||
element.visible = true;
|
||||
await element.updateComplete;
|
||||
|
||||
const cancelEventSpy = vi.fn();
|
||||
element.addEventListener('file-cancel', cancelEventSpy);
|
||||
|
||||
const buttons = element.querySelectorAll('button');
|
||||
const cancelButton = Array.from(buttons).find((btn) => btn.textContent?.includes('Cancel'));
|
||||
|
||||
expect(cancelButton).toBeTruthy();
|
||||
cancelButton?.click();
|
||||
|
||||
expect(cancelEventSpy).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('should emit file-cancel when clicking outside modal', async () => {
|
||||
element.visible = true;
|
||||
await element.updateComplete;
|
||||
|
||||
const cancelEventSpy = vi.fn();
|
||||
element.addEventListener('file-cancel', cancelEventSpy);
|
||||
|
||||
const backdrop = element.querySelector('.fixed');
|
||||
expect(backdrop).toBeTruthy();
|
||||
|
||||
backdrop?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
|
||||
expect(cancelEventSpy).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('should not emit file-cancel when clicking inside modal', async () => {
|
||||
element.visible = true;
|
||||
await element.updateComplete;
|
||||
|
||||
const cancelEventSpy = vi.fn();
|
||||
element.addEventListener('file-cancel', cancelEventSpy);
|
||||
|
||||
const modal = element.querySelector('.bg-white');
|
||||
expect(modal).toBeTruthy();
|
||||
|
||||
modal?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
|
||||
expect(cancelEventSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should disable cancel button when uploading', async () => {
|
||||
element.visible = true;
|
||||
element['uploading'] = true;
|
||||
await element.updateComplete;
|
||||
|
||||
const buttons = element.querySelectorAll('button');
|
||||
const cancelButton = Array.from(buttons).find((btn) => btn.textContent?.includes('Cancel'));
|
||||
|
||||
expect(cancelButton?.hasAttribute('disabled')).toBe(true);
|
||||
});
|
||||
|
||||
it('should create file input element on connect', () => {
|
||||
// The file input should be created when the component connects
|
||||
const fileInputs = document.querySelectorAll('input[type="file"]');
|
||||
expect(fileInputs.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should handle file input click', async () => {
|
||||
element.visible = true;
|
||||
await element.updateComplete;
|
||||
|
||||
const fileButton = Array.from(element.querySelectorAll('button')).find((btn) =>
|
||||
btn.textContent?.includes('Choose File')
|
||||
);
|
||||
|
||||
expect(fileButton).toBeTruthy();
|
||||
|
||||
// Mock file input
|
||||
const mockFileInput = {
|
||||
removeAttribute: vi.fn(),
|
||||
click: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
};
|
||||
element.fileInput = mockFileInput as any;
|
||||
|
||||
fileButton?.click();
|
||||
|
||||
expect(mockFileInput.removeAttribute).toHaveBeenCalledWith('capture');
|
||||
expect(mockFileInput.click).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should accept any file type', () => {
|
||||
const fileInputs = document.querySelectorAll('input[type="file"]');
|
||||
const fileInput = fileInputs[fileInputs.length - 1] as HTMLInputElement;
|
||||
expect(fileInput.accept).toBe('*/*');
|
||||
});
|
||||
|
||||
it('should clean up file input on disconnect', () => {
|
||||
const initialInputCount = document.querySelectorAll('input[type="file"]').length;
|
||||
|
||||
element.remove();
|
||||
|
||||
const finalInputCount = document.querySelectorAll('input[type="file"]').length;
|
||||
expect(finalInputCount).toBeLessThan(initialInputCount);
|
||||
});
|
||||
|
||||
it('should have uploadFile method for programmatic uploads', () => {
|
||||
expect(typeof element.uploadFile).toBe('function');
|
||||
});
|
||||
|
||||
it('should accept any file type in uploadFile method', async () => {
|
||||
const textFile = new File(['test'], 'test.txt', { type: 'text/plain' });
|
||||
|
||||
// Mock the XMLHttpRequest for this test
|
||||
const mockXHR = {
|
||||
upload: { addEventListener: vi.fn() },
|
||||
addEventListener: vi.fn((event, callback) => {
|
||||
if (event === 'load') {
|
||||
setTimeout(() => callback(), 0);
|
||||
}
|
||||
}),
|
||||
open: vi.fn(),
|
||||
setRequestHeader: vi.fn(),
|
||||
send: vi.fn(),
|
||||
status: 200,
|
||||
responseText: JSON.stringify({
|
||||
success: true,
|
||||
filename: 'test.txt',
|
||||
originalName: 'test.txt',
|
||||
size: 100,
|
||||
mimetype: 'text/plain',
|
||||
path: '/path/to/test.txt',
|
||||
relativePath: 'test.txt',
|
||||
}),
|
||||
};
|
||||
|
||||
// @ts-expect-error - Mocking XMLHttpRequest
|
||||
global.XMLHttpRequest = vi.fn(() => mockXHR);
|
||||
|
||||
// Should not throw an error for any file type
|
||||
await expect(element.uploadFile(textFile)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('should accept image files in uploadFile method', async () => {
|
||||
// Mock the XMLHttpRequest for this test
|
||||
const mockXHR = {
|
||||
upload: { addEventListener: vi.fn() },
|
||||
addEventListener: vi.fn(),
|
||||
open: vi.fn(),
|
||||
setRequestHeader: vi.fn(),
|
||||
send: vi.fn(),
|
||||
status: 200,
|
||||
responseText: JSON.stringify({
|
||||
success: true,
|
||||
filename: 'test.png',
|
||||
originalName: 'test.png',
|
||||
size: 100,
|
||||
mimetype: 'image/png',
|
||||
path: '/path/to/test.png',
|
||||
relativePath: 'uploads/test.png',
|
||||
}),
|
||||
};
|
||||
|
||||
// @ts-ignore
|
||||
global.XMLHttpRequest = vi.fn(() => mockXHR);
|
||||
|
||||
const imageFile = new File(['fake image'], 'test.png', { type: 'image/png' });
|
||||
|
||||
const fileSelectedSpy = vi.fn();
|
||||
element.addEventListener('file-selected', fileSelectedSpy);
|
||||
|
||||
const uploadPromise = element.uploadFile(imageFile);
|
||||
|
||||
// Simulate successful upload
|
||||
const loadHandler = mockXHR.addEventListener.mock.calls.find((call) => call[0] === 'load')[1];
|
||||
loadHandler();
|
||||
|
||||
await uploadPromise;
|
||||
|
||||
expect(fileSelectedSpy).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
245
web/src/client/components/file-picker.ts
Normal file
245
web/src/client/components/file-picker.ts
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
/**
|
||||
* File Picker Component
|
||||
*
|
||||
* Allows users to pick files from various sources,
|
||||
* upload them to the server, and send the path to the terminal.
|
||||
*
|
||||
* @fires file-selected - When a file is uploaded and ready (detail: { path: string })
|
||||
* @fires file-error - When an error occurs (detail: string)
|
||||
*/
|
||||
|
||||
import { html, LitElement } from 'lit';
|
||||
import { customElement, property, state } from 'lit/decorators.js';
|
||||
import { authClient } from '../services/auth-client.js';
|
||||
import { createLogger } from '../utils/logger.js';
|
||||
|
||||
const logger = createLogger('file-picker');
|
||||
|
||||
interface UploadResponse {
|
||||
success: boolean;
|
||||
filename: string;
|
||||
originalName: string;
|
||||
size: number;
|
||||
mimetype: string;
|
||||
path: string;
|
||||
relativePath: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
@customElement('file-picker')
|
||||
export class FilePicker extends LitElement {
|
||||
// Disable shadow DOM for Tailwind compatibility
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
@property({ type: Boolean }) visible = false;
|
||||
@property({ type: Boolean }) showPathOption = true; // Whether to show "Send path to terminal" option
|
||||
@state() private uploading = false;
|
||||
@state() private uploadProgress = 0;
|
||||
|
||||
private fileInput: HTMLInputElement | null = null;
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.createFileInput();
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
if (this.fileInput) {
|
||||
this.fileInput.remove();
|
||||
this.fileInput = null;
|
||||
}
|
||||
}
|
||||
|
||||
private createFileInput() {
|
||||
// Create a hidden file input element
|
||||
this.fileInput = document.createElement('input');
|
||||
this.fileInput.type = 'file';
|
||||
this.fileInput.accept = '*/*';
|
||||
this.fileInput.capture = 'environment'; // Use rear camera by default on mobile
|
||||
this.fileInput.style.display = 'none';
|
||||
this.fileInput.addEventListener('change', this.handleFileSelect.bind(this));
|
||||
document.body.appendChild(this.fileInput);
|
||||
}
|
||||
|
||||
private async handleFileSelect(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.uploadFileToServer(file);
|
||||
} catch (error) {
|
||||
logger.error('Failed to upload file:', error);
|
||||
this.dispatchEvent(
|
||||
new CustomEvent('file-error', {
|
||||
detail: error instanceof Error ? error.message : 'Failed to upload file',
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Reset the input value so the same file can be selected again
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Public method to upload a file programmatically (for drag & drop, paste)
|
||||
*/
|
||||
async uploadFile(file: File): Promise<void> {
|
||||
return this.uploadFileToServer(file);
|
||||
}
|
||||
|
||||
private async uploadFileToServer(file: File): Promise<void> {
|
||||
this.uploading = true;
|
||||
this.uploadProgress = 0;
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
// Create XMLHttpRequest for upload progress
|
||||
const xhr = new XMLHttpRequest();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
xhr.upload.addEventListener('progress', (e) => {
|
||||
if (e.lengthComputable) {
|
||||
this.uploadProgress = (e.loaded / e.total) * 100;
|
||||
}
|
||||
});
|
||||
|
||||
xhr.addEventListener('load', () => {
|
||||
this.uploading = false;
|
||||
|
||||
if (xhr.status === 200) {
|
||||
try {
|
||||
const response: UploadResponse = JSON.parse(xhr.responseText);
|
||||
if (response.success) {
|
||||
logger.log(`File uploaded successfully: ${response.filename}`);
|
||||
this.dispatchEvent(
|
||||
new CustomEvent('file-selected', {
|
||||
detail: {
|
||||
path: response.path,
|
||||
relativePath: response.relativePath,
|
||||
filename: response.filename,
|
||||
originalName: response.originalName,
|
||||
size: response.size,
|
||||
mimetype: response.mimetype,
|
||||
},
|
||||
})
|
||||
);
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(response.error || 'Upload failed'));
|
||||
}
|
||||
} catch (_error) {
|
||||
reject(new Error('Invalid response from server'));
|
||||
}
|
||||
} else {
|
||||
reject(new Error(`Upload failed with status ${xhr.status}`));
|
||||
}
|
||||
});
|
||||
|
||||
xhr.addEventListener('error', () => {
|
||||
this.uploading = false;
|
||||
reject(new Error('Upload failed'));
|
||||
});
|
||||
|
||||
xhr.addEventListener('abort', () => {
|
||||
this.uploading = false;
|
||||
reject(new Error('Upload aborted'));
|
||||
});
|
||||
|
||||
xhr.open('POST', '/api/files/upload');
|
||||
|
||||
// Add auth headers
|
||||
const authHeaders = authClient.getAuthHeader();
|
||||
for (const [key, value] of Object.entries(authHeaders)) {
|
||||
xhr.setRequestHeader(key, value);
|
||||
}
|
||||
|
||||
xhr.send(formData);
|
||||
});
|
||||
} catch (error) {
|
||||
this.uploading = false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private handleFileClick() {
|
||||
if (!this.fileInput) {
|
||||
this.createFileInput();
|
||||
}
|
||||
|
||||
if (this.fileInput) {
|
||||
// Remove capture attribute for general file selection
|
||||
this.fileInput.removeAttribute('capture');
|
||||
this.fileInput.click();
|
||||
}
|
||||
}
|
||||
|
||||
private handleCancel() {
|
||||
this.dispatchEvent(new CustomEvent('file-cancel'));
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.visible) {
|
||||
return html``;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50" @click=${this.handleCancel}>
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg p-6 m-4 max-w-sm w-full" @click=${(e: Event) => e.stopPropagation()}>
|
||||
<h3 class="text-lg font-semibold mb-4 text-gray-900 dark:text-white">
|
||||
Select File
|
||||
</h3>
|
||||
|
||||
${
|
||||
this.uploading
|
||||
? html`
|
||||
<div class="mb-4">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400">Uploading...</span>
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400">${Math.round(this.uploadProgress)}%</span>
|
||||
</div>
|
||||
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
||||
<div
|
||||
class="bg-blue-500 h-2 rounded-full transition-all duration-300"
|
||||
style="width: ${this.uploadProgress}%"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
: html`
|
||||
<div class="space-y-3">
|
||||
<button
|
||||
@click=${this.handleFileClick}
|
||||
class="w-full bg-blue-500 hover:bg-blue-600 text-white font-medium py-3 px-4 rounded-lg flex items-center justify-center space-x-2 transition-colors"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
|
||||
</svg>
|
||||
<span>Choose File</span>
|
||||
</button>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
<div class="mt-4 pt-4 border-t border-gray-200 dark:border-gray-600">
|
||||
<button
|
||||
@click=${this.handleCancel}
|
||||
class="w-full bg-gray-300 hover:bg-gray-400 dark:bg-gray-600 dark:hover:bg-gray-500 text-gray-700 dark:text-gray-200 font-medium py-2 px-4 rounded-lg transition-colors"
|
||||
?disabled=${this.uploading}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
446
web/src/client/components/session-view-drag-drop.test.ts
Normal file
446
web/src/client/components/session-view-drag-drop.test.ts
Normal file
|
|
@ -0,0 +1,446 @@
|
|||
/**
|
||||
* Unit tests for SessionView drag & drop and paste functionality
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import './session-view.js';
|
||||
import type { SessionView } from './session-view.js';
|
||||
|
||||
// Mock auth client
|
||||
vi.mock('../services/auth-client.js', () => ({
|
||||
authClient: {
|
||||
getAuthHeader: () => ({ Authorization: 'Bearer test-token' }),
|
||||
getCurrentUser: () => ({ username: 'test-user' }),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock logger
|
||||
vi.mock('../utils/logger.js', () => ({
|
||||
createLogger: () => ({
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock other dependencies
|
||||
vi.mock('../utils/terminal-preferences.js', () => ({
|
||||
TerminalPreferencesManager: {
|
||||
getInstance: () => ({
|
||||
getFontSize: () => 14,
|
||||
getMaxCols: () => 0,
|
||||
setMaxCols: vi.fn(),
|
||||
}),
|
||||
},
|
||||
COMMON_TERMINAL_WIDTHS: [
|
||||
{ label: '80', value: 80 },
|
||||
{ label: '120', value: 120 },
|
||||
],
|
||||
}));
|
||||
|
||||
describe('SessionView Drag & Drop and Paste', () => {
|
||||
let element: SessionView;
|
||||
let container: HTMLElement;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
container.innerHTML = '<session-view></session-view>';
|
||||
element = container.querySelector('session-view') as SessionView;
|
||||
|
||||
// Set up a mock session
|
||||
element.session = {
|
||||
id: 'test-session',
|
||||
name: 'Test Session',
|
||||
command: ['bash'],
|
||||
workingDir: '/test',
|
||||
status: 'running',
|
||||
startedAt: new Date().toISOString(),
|
||||
lastModified: new Date().toISOString(),
|
||||
} as any;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
container.remove();
|
||||
});
|
||||
|
||||
describe('Drag & Drop', () => {
|
||||
it('should prevent default on dragover with files', async () => {
|
||||
// Create a mock drag event with files
|
||||
const preventDefault = vi.fn();
|
||||
const stopPropagation = vi.fn();
|
||||
|
||||
const dragEvent = new DragEvent('dragover', {
|
||||
bubbles: true,
|
||||
});
|
||||
|
||||
// Mock dataTransfer to include Files type
|
||||
Object.defineProperty(dragEvent, 'dataTransfer', {
|
||||
value: {
|
||||
types: ['Files'],
|
||||
preventDefault: vi.fn(),
|
||||
effectAllowed: 'all',
|
||||
dropEffect: 'copy',
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
Object.defineProperty(dragEvent, 'preventDefault', {
|
||||
value: preventDefault,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
Object.defineProperty(dragEvent, 'stopPropagation', {
|
||||
value: stopPropagation,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
element.dispatchEvent(dragEvent);
|
||||
|
||||
// Verify event was handled
|
||||
expect(preventDefault).toHaveBeenCalled();
|
||||
expect(stopPropagation).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle dragleave event properly', async () => {
|
||||
const preventDefault = vi.fn();
|
||||
const stopPropagation = vi.fn();
|
||||
|
||||
const dragLeaveEvent = new DragEvent('dragleave', {
|
||||
bubbles: true,
|
||||
clientX: -100, // Outside the element bounds
|
||||
clientY: -100,
|
||||
});
|
||||
|
||||
Object.defineProperty(dragLeaveEvent, 'preventDefault', {
|
||||
value: preventDefault,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
Object.defineProperty(dragLeaveEvent, 'stopPropagation', {
|
||||
value: stopPropagation,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
// Mock getBoundingClientRect
|
||||
element.getBoundingClientRect = vi.fn(() => ({
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: 100,
|
||||
bottom: 100,
|
||||
width: 100,
|
||||
height: 100,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: vi.fn(),
|
||||
}));
|
||||
|
||||
element.dispatchEvent(dragLeaveEvent);
|
||||
|
||||
// Verify event was handled
|
||||
expect(preventDefault).toHaveBeenCalled();
|
||||
expect(stopPropagation).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle file drop', async () => {
|
||||
const testFile = new File(['fake content'], 'test.txt', { type: 'text/plain' });
|
||||
|
||||
const dropEvent = new DragEvent('drop', {
|
||||
bubbles: true,
|
||||
});
|
||||
|
||||
// Create a mock FileList
|
||||
const files = [testFile];
|
||||
Object.defineProperty(files, 'item', {
|
||||
value: (index: number) => files[index] || null,
|
||||
});
|
||||
Object.defineProperty(files, 'length', {
|
||||
value: files.length,
|
||||
});
|
||||
|
||||
// Mock the entire dataTransfer object on the event
|
||||
Object.defineProperty(dropEvent, 'dataTransfer', {
|
||||
value: {
|
||||
files: files,
|
||||
types: ['Files'],
|
||||
preventDefault: vi.fn(),
|
||||
effectAllowed: 'all',
|
||||
dropEffect: 'copy',
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
// Mock the file picker component
|
||||
const mockFilePicker = {
|
||||
uploadFile: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
// Override querySelector to return our mock
|
||||
const originalQuerySelector = element.querySelector.bind(element);
|
||||
element.querySelector = vi.fn((selector: string) => {
|
||||
if (selector === 'file-picker') {
|
||||
return mockFilePicker;
|
||||
}
|
||||
return originalQuerySelector(selector);
|
||||
});
|
||||
|
||||
element.dispatchEvent(dropEvent);
|
||||
|
||||
// Wait for async operations
|
||||
await vi.waitFor(() => {
|
||||
expect(mockFilePicker.uploadFile).toHaveBeenCalledWith(testFile);
|
||||
});
|
||||
|
||||
// Verify overlay is hidden after drop
|
||||
const overlayAfterDrop = element.shadowRoot?.querySelector(
|
||||
'.fixed.inset-0.bg-black.bg-opacity-80'
|
||||
);
|
||||
expect(overlayAfterDrop).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should handle empty file drops gracefully', async () => {
|
||||
const dropEvent = new DragEvent('drop', {
|
||||
bubbles: true,
|
||||
});
|
||||
|
||||
// Mock the dataTransfer with empty files
|
||||
Object.defineProperty(dropEvent, 'dataTransfer', {
|
||||
value: {
|
||||
files: [],
|
||||
types: [],
|
||||
preventDefault: vi.fn(),
|
||||
effectAllowed: 'all',
|
||||
dropEffect: 'copy',
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
const mockFilePicker = {
|
||||
uploadFile: vi.fn(),
|
||||
};
|
||||
element.querySelector = vi.fn(() => mockFilePicker);
|
||||
|
||||
element.dispatchEvent(dropEvent);
|
||||
await element.updateComplete;
|
||||
|
||||
expect(mockFilePicker.uploadFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle multiple files and pick the first one', async () => {
|
||||
const textFile = new File(['text'], 'test.txt', { type: 'text/plain' });
|
||||
const jsonFile = new File(['{}'], 'test.json', { type: 'application/json' });
|
||||
const pdfFile = new File(['pdf'], 'test.pdf', { type: 'application/pdf' });
|
||||
|
||||
const dropEvent = new DragEvent('drop', {
|
||||
bubbles: true,
|
||||
});
|
||||
|
||||
// Create a mock FileList with multiple files
|
||||
const files = [textFile, jsonFile, pdfFile];
|
||||
Object.defineProperty(files, 'item', {
|
||||
value: (index: number) => files[index] || null,
|
||||
});
|
||||
Object.defineProperty(files, 'length', {
|
||||
value: files.length,
|
||||
});
|
||||
|
||||
// Mock the entire dataTransfer object on the event
|
||||
Object.defineProperty(dropEvent, 'dataTransfer', {
|
||||
value: {
|
||||
files: files,
|
||||
types: ['Files'],
|
||||
preventDefault: vi.fn(),
|
||||
effectAllowed: 'all',
|
||||
dropEffect: 'copy',
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
const mockFilePicker = {
|
||||
uploadFile: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
// Override querySelector to return our mock
|
||||
const originalQuerySelector = element.querySelector.bind(element);
|
||||
element.querySelector = vi.fn((selector: string) => {
|
||||
if (selector === 'file-picker') {
|
||||
return mockFilePicker;
|
||||
}
|
||||
return originalQuerySelector(selector);
|
||||
});
|
||||
|
||||
element.dispatchEvent(dropEvent);
|
||||
|
||||
// Wait for async operations
|
||||
await vi.waitFor(() => {
|
||||
expect(mockFilePicker.uploadFile).toHaveBeenCalledWith(textFile);
|
||||
});
|
||||
|
||||
expect(mockFilePicker.uploadFile).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Paste Functionality', () => {
|
||||
it('should handle file paste from clipboard', async () => {
|
||||
const testFile = new File(['fake content'], 'clipboard.txt', { type: 'text/plain' });
|
||||
|
||||
// Mock clipboard item
|
||||
const mockClipboardItem = {
|
||||
kind: 'file',
|
||||
type: 'text/plain',
|
||||
getAsFile: () => testFile,
|
||||
};
|
||||
|
||||
const pasteEvent = new ClipboardEvent('paste', {
|
||||
bubbles: true,
|
||||
clipboardData: new DataTransfer(),
|
||||
});
|
||||
|
||||
// Mock clipboardData.items
|
||||
Object.defineProperty(pasteEvent.clipboardData, 'items', {
|
||||
value: [mockClipboardItem],
|
||||
writable: false,
|
||||
});
|
||||
|
||||
const mockFilePicker = {
|
||||
uploadFile: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
element.querySelector = vi.fn(() => mockFilePicker);
|
||||
|
||||
// Simulate paste event on document
|
||||
document.dispatchEvent(pasteEvent);
|
||||
|
||||
expect(mockFilePicker.uploadFile).toHaveBeenCalledWith(testFile);
|
||||
});
|
||||
|
||||
it('should ignore paste when modals are open', async () => {
|
||||
element['showFileBrowser'] = true;
|
||||
|
||||
const testFile = new File(['fake content'], 'clipboard.txt', { type: 'text/plain' });
|
||||
const mockClipboardItem = {
|
||||
kind: 'file',
|
||||
type: 'text/plain',
|
||||
getAsFile: () => testFile,
|
||||
};
|
||||
|
||||
const pasteEvent = new ClipboardEvent('paste', {
|
||||
bubbles: true,
|
||||
clipboardData: new DataTransfer(),
|
||||
});
|
||||
|
||||
Object.defineProperty(pasteEvent.clipboardData, 'items', {
|
||||
value: [mockClipboardItem],
|
||||
writable: false,
|
||||
});
|
||||
|
||||
const mockFilePicker = {
|
||||
uploadFile: vi.fn(),
|
||||
};
|
||||
element.querySelector = vi.fn(() => mockFilePicker);
|
||||
|
||||
document.dispatchEvent(pasteEvent);
|
||||
|
||||
expect(mockFilePicker.uploadFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should ignore paste of non-file content', async () => {
|
||||
const textItem = {
|
||||
kind: 'string',
|
||||
type: 'text/plain',
|
||||
getAsFile: () => null,
|
||||
};
|
||||
|
||||
const pasteEvent = new ClipboardEvent('paste', {
|
||||
bubbles: true,
|
||||
clipboardData: new DataTransfer(),
|
||||
});
|
||||
|
||||
Object.defineProperty(pasteEvent.clipboardData, 'items', {
|
||||
value: [textItem],
|
||||
writable: false,
|
||||
});
|
||||
|
||||
const mockFilePicker = {
|
||||
uploadFile: vi.fn(),
|
||||
};
|
||||
element.querySelector = vi.fn(() => mockFilePicker);
|
||||
|
||||
document.dispatchEvent(pasteEvent);
|
||||
|
||||
expect(mockFilePicker.uploadFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle paste error gracefully', async () => {
|
||||
const imageFile = new File(['fake image'], 'clipboard.png', { type: 'image/png' });
|
||||
const mockClipboardItem = {
|
||||
kind: 'file',
|
||||
type: 'image/png',
|
||||
getAsFile: () => imageFile,
|
||||
};
|
||||
|
||||
const pasteEvent = new ClipboardEvent('paste', {
|
||||
bubbles: true,
|
||||
clipboardData: new DataTransfer(),
|
||||
});
|
||||
|
||||
Object.defineProperty(pasteEvent.clipboardData, 'items', {
|
||||
value: [mockClipboardItem],
|
||||
writable: false,
|
||||
});
|
||||
|
||||
const mockImagePicker = {
|
||||
uploadFile: vi.fn().mockRejectedValue(new Error('Upload failed')),
|
||||
};
|
||||
|
||||
// Override querySelector to return our mock
|
||||
const originalQuerySelector = element.querySelector.bind(element);
|
||||
element.querySelector = vi.fn((selector: string) => {
|
||||
if (selector === 'file-picker') {
|
||||
return mockImagePicker;
|
||||
}
|
||||
return originalQuerySelector(selector);
|
||||
});
|
||||
|
||||
const errorSpy = vi.fn();
|
||||
element.addEventListener('error', errorSpy);
|
||||
|
||||
document.dispatchEvent(pasteEvent);
|
||||
|
||||
// Wait for the error event to be dispatched
|
||||
await vi.waitFor(() => {
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
detail: 'Upload failed',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Event Listener Management', () => {
|
||||
it('should add event listeners on connect', () => {
|
||||
const addEventListenerSpy = vi.spyOn(element, 'addEventListener');
|
||||
const documentAddSpy = vi.spyOn(document, 'addEventListener');
|
||||
|
||||
element.connectedCallback();
|
||||
|
||||
expect(addEventListenerSpy).toHaveBeenCalledWith('dragover', expect.any(Function));
|
||||
expect(addEventListenerSpy).toHaveBeenCalledWith('dragleave', expect.any(Function));
|
||||
expect(addEventListenerSpy).toHaveBeenCalledWith('drop', expect.any(Function));
|
||||
expect(documentAddSpy).toHaveBeenCalledWith('paste', expect.any(Function));
|
||||
});
|
||||
|
||||
it('should remove event listeners on disconnect', () => {
|
||||
const removeEventListenerSpy = vi.spyOn(element, 'removeEventListener');
|
||||
const documentRemoveSpy = vi.spyOn(document, 'removeEventListener');
|
||||
|
||||
element.disconnectedCallback();
|
||||
|
||||
expect(removeEventListenerSpy).toHaveBeenCalledWith('dragover', expect.any(Function));
|
||||
expect(removeEventListenerSpy).toHaveBeenCalledWith('dragleave', expect.any(Function));
|
||||
expect(removeEventListenerSpy).toHaveBeenCalledWith('drop', expect.any(Function));
|
||||
expect(documentRemoveSpy).toHaveBeenCalledWith('paste', expect.any(Function));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -18,6 +18,8 @@ import { customElement, property, state } from 'lit/decorators.js';
|
|||
import type { Session } from './session-list.js';
|
||||
import './terminal.js';
|
||||
import './file-browser.js';
|
||||
import './file-picker.js';
|
||||
import type { FilePicker } from './file-picker.js';
|
||||
import './clickable-path.js';
|
||||
import './terminal-quick-keys.js';
|
||||
import './session-view/mobile-input-overlay.js';
|
||||
|
|
@ -74,10 +76,18 @@ export class SessionView extends LitElement {
|
|||
@state() private showWidthSelector = false;
|
||||
@state() private customWidth = '';
|
||||
@state() private showFileBrowser = false;
|
||||
@state() private showImagePicker = false;
|
||||
@state() private isDragOver = false;
|
||||
@state() private terminalFontSize = 14;
|
||||
@state() private terminalContainerHeight = '100%';
|
||||
|
||||
private preferencesManager = TerminalPreferencesManager.getInstance();
|
||||
|
||||
// Bound event handlers to ensure proper cleanup
|
||||
private boundHandleDragOver = this.handleDragOver.bind(this);
|
||||
private boundHandleDragLeave = this.handleDragLeave.bind(this);
|
||||
private boundHandleDrop = this.handleDrop.bind(this);
|
||||
private boundHandlePaste = this.handlePaste.bind(this);
|
||||
private connectionManager!: ConnectionManager;
|
||||
private inputManager!: InputManager;
|
||||
private mobileInputManager!: MobileInputManager;
|
||||
|
|
@ -335,11 +345,23 @@ export class SessionView extends LitElement {
|
|||
|
||||
// Set up lifecycle (replaces the extracted lifecycle logic)
|
||||
this.lifecycleEventManager.setupLifecycle();
|
||||
|
||||
// Add drag & drop and paste event listeners
|
||||
this.addEventListener('dragover', this.boundHandleDragOver);
|
||||
this.addEventListener('dragleave', this.boundHandleDragLeave);
|
||||
this.addEventListener('drop', this.boundHandleDrop);
|
||||
document.addEventListener('paste', this.boundHandlePaste);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
|
||||
// Remove drag & drop and paste event listeners
|
||||
this.removeEventListener('dragover', this.boundHandleDragOver);
|
||||
this.removeEventListener('dragleave', this.boundHandleDragLeave);
|
||||
this.removeEventListener('drop', this.boundHandleDrop);
|
||||
document.removeEventListener('paste', this.boundHandlePaste);
|
||||
|
||||
// Clear any pending timeout
|
||||
if (this.createHiddenInputTimeout) {
|
||||
clearTimeout(this.createHiddenInputTimeout);
|
||||
|
|
@ -728,6 +750,125 @@ export class SessionView extends LitElement {
|
|||
this.showFileBrowser = false;
|
||||
}
|
||||
|
||||
private handleOpenFilePicker() {
|
||||
this.showImagePicker = true;
|
||||
}
|
||||
|
||||
private handleCloseFilePicker() {
|
||||
this.showImagePicker = false;
|
||||
}
|
||||
|
||||
private async handleFileSelected(event: CustomEvent) {
|
||||
const { path } = event.detail;
|
||||
if (!path || !this.session) return;
|
||||
|
||||
// Close the file picker
|
||||
this.showImagePicker = false;
|
||||
|
||||
// Escape the path for shell use (wrap in quotes if it contains spaces)
|
||||
const escapedPath = path.includes(' ') ? `"${path}"` : path;
|
||||
|
||||
// Send the path to the terminal
|
||||
if (this.inputManager) {
|
||||
await this.inputManager.sendInputText(escapedPath);
|
||||
}
|
||||
|
||||
logger.log(`inserted file path into terminal: ${escapedPath}`);
|
||||
}
|
||||
|
||||
private handleFileError(event: CustomEvent) {
|
||||
const error = event.detail;
|
||||
logger.error('File picker error:', error);
|
||||
|
||||
// Show error to user (you might want to implement a toast notification system)
|
||||
this.dispatchEvent(new CustomEvent('error', { detail: error }));
|
||||
}
|
||||
|
||||
// Drag & Drop handlers
|
||||
private handleDragOver(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// Check if the drag contains files
|
||||
if (e.dataTransfer?.types.includes('Files')) {
|
||||
this.isDragOver = true;
|
||||
}
|
||||
}
|
||||
|
||||
private handleDragLeave(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// Only hide drag overlay if we're leaving the main container
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
const x = e.clientX;
|
||||
const y = e.clientY;
|
||||
|
||||
if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) {
|
||||
this.isDragOver = false;
|
||||
}
|
||||
}
|
||||
|
||||
private handleDrop(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.isDragOver = false;
|
||||
|
||||
const files = Array.from(e.dataTransfer?.files || []);
|
||||
|
||||
if (files.length === 0) {
|
||||
logger.warn('No files found in drop');
|
||||
return;
|
||||
}
|
||||
|
||||
// Upload the first file (or we could upload all of them)
|
||||
this.uploadFile(files[0]);
|
||||
}
|
||||
|
||||
// Paste handler
|
||||
private handlePaste(e: ClipboardEvent) {
|
||||
// Only handle paste if session view is focused and no modal is open
|
||||
if (this.showFileBrowser || this.showImagePicker || this.showMobileInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
const items = Array.from(e.clipboardData?.items || []);
|
||||
const fileItems = items.filter((item) => item.kind === 'file');
|
||||
|
||||
if (fileItems.length === 0) {
|
||||
return; // Let normal paste handling continue
|
||||
}
|
||||
|
||||
e.preventDefault(); // Prevent default paste behavior for files
|
||||
|
||||
const fileItem = fileItems[0];
|
||||
const file = fileItem.getAsFile();
|
||||
|
||||
if (file) {
|
||||
logger.log('File pasted from clipboard');
|
||||
this.uploadFile(file);
|
||||
}
|
||||
}
|
||||
|
||||
private async uploadFile(file: File) {
|
||||
try {
|
||||
// Get the file picker component and use its upload method
|
||||
const filePicker = this.querySelector('file-picker') as FilePicker | null;
|
||||
if (filePicker && typeof filePicker.uploadFile === 'function') {
|
||||
await filePicker.uploadFile(file);
|
||||
} else {
|
||||
logger.error('File picker component not found or upload method not available');
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to upload dropped/pasted file:', error);
|
||||
this.dispatchEvent(
|
||||
new CustomEvent('error', {
|
||||
detail: error instanceof Error ? error.message : 'Failed to upload file',
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleInsertPath(event: CustomEvent) {
|
||||
const { path, type } = event.detail;
|
||||
if (!path || !this.session) return;
|
||||
|
|
@ -890,6 +1031,7 @@ export class SessionView extends LitElement {
|
|||
.onBack=${() => this.handleBack()}
|
||||
.onSidebarToggle=${() => this.handleSidebarToggle()}
|
||||
.onOpenFileBrowser=${() => this.handleOpenFileBrowser()}
|
||||
.onOpenImagePicker=${() => this.handleOpenFilePicker()}
|
||||
.onMaxWidthToggle=${() => this.handleMaxWidthToggle()}
|
||||
.onWidthSelect=${(width: number) => this.handleWidthSelect(width)}
|
||||
.onFontSizeChange=${(size: number) => this.handleFontSizeChange(size)}
|
||||
|
|
@ -1011,6 +1153,13 @@ export class SessionView extends LitElement {
|
|||
>
|
||||
ABC123
|
||||
</button>
|
||||
<button
|
||||
class="font-mono text-sm transition-all cursor-pointer w-16 quick-start-btn"
|
||||
@click=${this.handleOpenFilePicker}
|
||||
title="Upload file"
|
||||
>
|
||||
📷
|
||||
</button>
|
||||
<button
|
||||
class="font-mono text-sm transition-all cursor-pointer w-16 quick-start-btn"
|
||||
@click=${this.handleCtrlAlphaToggle}
|
||||
|
|
@ -1093,6 +1242,30 @@ export class SessionView extends LitElement {
|
|||
@browser-cancel=${this.handleCloseFileBrowser}
|
||||
@insert-path=${this.handleInsertPath}
|
||||
></file-browser>
|
||||
|
||||
<!-- File Picker Modal -->
|
||||
<file-picker
|
||||
.visible=${this.showImagePicker}
|
||||
@file-selected=${this.handleFileSelected}
|
||||
@file-error=${this.handleFileError}
|
||||
@file-cancel=${this.handleCloseFilePicker}
|
||||
></file-picker>
|
||||
|
||||
<!-- Drag & Drop Overlay -->
|
||||
${
|
||||
this.isDragOver
|
||||
? html`
|
||||
<div class="fixed inset-0 bg-black bg-opacity-80 flex items-center justify-center z-50 pointer-events-none">
|
||||
<div class="bg-dark-bg-secondary border-2 border-dashed border-terminal-green text-terminal-green rounded-lg p-8 text-center">
|
||||
<div class="text-6xl mb-4">📁</div>
|
||||
<div class="text-xl font-semibold mb-2">Drop files here</div>
|
||||
<div class="text-sm opacity-80">Files will be uploaded and the path sent to terminal</div>
|
||||
<div class="text-xs opacity-60 mt-2">Or press CMD+V to paste from clipboard</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
: ''
|
||||
}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ export class SessionHeader extends LitElement {
|
|||
@property({ type: Function }) onBack?: () => void;
|
||||
@property({ type: Function }) onSidebarToggle?: () => void;
|
||||
@property({ type: Function }) onOpenFileBrowser?: () => void;
|
||||
@property({ type: Function }) onOpenImagePicker?: () => void;
|
||||
@property({ type: Function }) onMaxWidthToggle?: () => void;
|
||||
@property({ type: Function }) onWidthSelect?: (width: number) => void;
|
||||
@property({ type: Function }) onFontSizeChange?: (size: number) => void;
|
||||
|
|
@ -156,6 +157,16 @@ export class SessionHeader extends LitElement {
|
|||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
class="btn-secondary font-mono text-xs p-1 flex-shrink-0"
|
||||
@click=${() => this.onOpenImagePicker?.()}
|
||||
title="Upload File"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8l-6-6z" stroke="currentColor" stroke-width="2"/>
|
||||
<path d="M14 2v6h6M16 13H8M16 17H8M10 9H8" stroke="currentColor" stroke-width="2"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
class="btn-secondary font-mono text-xs px-2 py-1 flex-shrink-0 width-selector-button"
|
||||
@click=${() => this.onMaxWidthToggle?.()}
|
||||
|
|
|
|||
224
web/src/server/routes/files.ts
Normal file
224
web/src/server/routes/files.ts
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
import { Router } from 'express';
|
||||
import * as fs from 'fs';
|
||||
import { access, readdir, stat, unlink } from 'fs/promises';
|
||||
import * as mime from 'mime-types';
|
||||
import multer from 'multer';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import type { AuthenticatedRequest } from '../middleware/auth.js';
|
||||
import { createLogger } from '../utils/logger.js';
|
||||
|
||||
const logger = createLogger('files');
|
||||
|
||||
// Create uploads directory in the control directory
|
||||
const CONTROL_DIR =
|
||||
process.env.VIBETUNNEL_CONTROL_DIR || path.join(os.homedir(), '.vibetunnel/control');
|
||||
const UPLOADS_DIR = path.join(CONTROL_DIR, 'uploads');
|
||||
|
||||
// Ensure uploads directory exists
|
||||
if (!fs.existsSync(UPLOADS_DIR)) {
|
||||
fs.mkdirSync(UPLOADS_DIR, { recursive: true });
|
||||
logger.log(`Created uploads directory: ${UPLOADS_DIR}`);
|
||||
}
|
||||
|
||||
// Configure multer for file uploads
|
||||
const storage = multer.diskStorage({
|
||||
destination: (_req, _file, cb) => {
|
||||
cb(null, UPLOADS_DIR);
|
||||
},
|
||||
filename: (_req, file, cb) => {
|
||||
// Generate unique filename with original extension
|
||||
const uniqueName = `${uuidv4()}${path.extname(file.originalname)}`;
|
||||
cb(null, uniqueName);
|
||||
},
|
||||
});
|
||||
|
||||
// File filter configuration
|
||||
// Note: We intentionally do not restrict file types to provide maximum flexibility
|
||||
// for users. While the terminal display may not support all file formats (e.g.,
|
||||
// binary files, executables), users should be able to upload any file they need
|
||||
// and receive the path in their terminal for further processing.
|
||||
const fileFilter: multer.Options['fileFilter'] = (_req, _file, cb) => {
|
||||
// Accept all file types - no restrictions by design
|
||||
cb(null, true);
|
||||
};
|
||||
|
||||
const upload = multer({
|
||||
storage,
|
||||
fileFilter,
|
||||
limits: {
|
||||
fileSize: 100 * 1024 * 1024, // 100MB limit for general files
|
||||
},
|
||||
});
|
||||
|
||||
export function createFileRoutes(): Router {
|
||||
const router = Router();
|
||||
|
||||
// Upload file endpoint
|
||||
router.post(
|
||||
'/files/upload',
|
||||
upload.single('file'),
|
||||
(req: AuthenticatedRequest & { file?: Express.Multer.File }, res) => {
|
||||
try {
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ error: 'No file provided' });
|
||||
}
|
||||
|
||||
// Generate relative path for the terminal
|
||||
const relativePath = path.relative(process.cwd(), req.file.path);
|
||||
const absolutePath = req.file.path;
|
||||
|
||||
logger.log(
|
||||
`File uploaded by user ${req.userId}: ${req.file.filename} (${req.file.size} bytes)`
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
filename: req.file.filename,
|
||||
originalName: req.file.originalname,
|
||||
size: req.file.size,
|
||||
mimetype: req.file.mimetype,
|
||||
path: absolutePath,
|
||||
relativePath: relativePath,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('File upload error:', error);
|
||||
res.status(500).json({ error: 'Failed to upload file' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Serve uploaded files
|
||||
router.get('/files/:filename', async (req, res) => {
|
||||
try {
|
||||
const filename = req.params.filename;
|
||||
const filePath = path.join(UPLOADS_DIR, filename);
|
||||
|
||||
// Security check: ensure filename doesn't contain path traversal
|
||||
// Only allow alphanumeric, hyphens, underscores, dots, and standard file extension patterns
|
||||
if (
|
||||
filename.includes('..') ||
|
||||
filename.includes('/') ||
|
||||
filename.includes('\\') ||
|
||||
filename.includes('\0') ||
|
||||
!/^[a-zA-Z0-9._-]+$/.test(filename) ||
|
||||
filename.startsWith('.') ||
|
||||
filename.length > 255
|
||||
) {
|
||||
return res.status(400).json({ error: 'Invalid filename' });
|
||||
}
|
||||
|
||||
// Ensure the resolved path is within the uploads directory
|
||||
const resolvedPath = path.resolve(filePath);
|
||||
const resolvedUploadsDir = path.resolve(UPLOADS_DIR);
|
||||
if (
|
||||
!resolvedPath.startsWith(resolvedUploadsDir + path.sep) &&
|
||||
resolvedPath !== resolvedUploadsDir
|
||||
) {
|
||||
return res.status(400).json({ error: 'Invalid file path' });
|
||||
}
|
||||
|
||||
// Check if file exists
|
||||
try {
|
||||
await access(filePath);
|
||||
} catch {
|
||||
return res.status(404).json({ error: 'File not found' });
|
||||
}
|
||||
|
||||
// Get file stats for content length
|
||||
const stats = await stat(filePath);
|
||||
|
||||
// Use mime-types library to determine content type
|
||||
// It automatically falls back to 'application/octet-stream' for unknown types
|
||||
const contentType = mime.lookup(filename) || 'application/octet-stream';
|
||||
|
||||
res.setHeader('Content-Type', contentType);
|
||||
res.setHeader('Content-Length', stats.size);
|
||||
res.setHeader('Cache-Control', 'public, max-age=86400'); // Cache for 1 day
|
||||
|
||||
// Stream the file
|
||||
const fileStream = fs.createReadStream(filePath);
|
||||
fileStream.pipe(res);
|
||||
} catch (error) {
|
||||
logger.error('File serve error:', error);
|
||||
res.status(500).json({ error: 'Failed to serve file' });
|
||||
}
|
||||
});
|
||||
|
||||
// List uploaded files
|
||||
router.get('/files', async (_req: AuthenticatedRequest, res) => {
|
||||
try {
|
||||
const allFiles = await readdir(UPLOADS_DIR);
|
||||
const files = await Promise.all(
|
||||
allFiles.map(async (file) => {
|
||||
const filePath = path.join(UPLOADS_DIR, file);
|
||||
const stats = await stat(filePath);
|
||||
return {
|
||||
filename: file,
|
||||
size: stats.size,
|
||||
createdAt: stats.birthtime,
|
||||
modifiedAt: stats.mtime,
|
||||
url: `/api/files/${file}`,
|
||||
extension: path.extname(file).toLowerCase(),
|
||||
};
|
||||
})
|
||||
);
|
||||
files.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); // Sort by newest first
|
||||
|
||||
res.json({
|
||||
files,
|
||||
count: files.length,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('File list error:', error);
|
||||
res.status(500).json({ error: 'Failed to list files' });
|
||||
}
|
||||
});
|
||||
|
||||
// Delete uploaded file
|
||||
router.delete('/files/:filename', async (req: AuthenticatedRequest, res) => {
|
||||
try {
|
||||
const filename = req.params.filename;
|
||||
|
||||
// Security check: ensure filename doesn't contain path traversal
|
||||
if (
|
||||
filename.includes('..') ||
|
||||
filename.includes('/') ||
|
||||
filename.includes('\\') ||
|
||||
filename.includes('\0') ||
|
||||
!/^[a-zA-Z0-9._-]+$/.test(filename) ||
|
||||
filename.startsWith('.') ||
|
||||
filename.length > 255
|
||||
) {
|
||||
return res.status(400).json({ error: 'Invalid filename' });
|
||||
}
|
||||
|
||||
const filePath = path.join(UPLOADS_DIR, filename);
|
||||
|
||||
// Ensure the resolved path is within the uploads directory
|
||||
const resolvedPath = path.resolve(filePath);
|
||||
const resolvedUploadsDir = path.resolve(UPLOADS_DIR);
|
||||
if (
|
||||
!resolvedPath.startsWith(resolvedUploadsDir + path.sep) &&
|
||||
resolvedPath !== resolvedUploadsDir
|
||||
) {
|
||||
return res.status(400).json({ error: 'Invalid file path' });
|
||||
}
|
||||
|
||||
try {
|
||||
await unlink(filePath);
|
||||
logger.log(`File deleted by user ${req.userId}: ${filename}`);
|
||||
res.json({ success: true, message: 'File deleted successfully' });
|
||||
} catch {
|
||||
// File doesn't exist
|
||||
res.status(404).json({ error: 'File not found' });
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('File deletion error:', error);
|
||||
res.status(500).json({ error: 'Failed to delete file' });
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
|
@ -12,6 +12,7 @@ import type { AuthenticatedRequest } from './middleware/auth.js';
|
|||
import { createAuthMiddleware } from './middleware/auth.js';
|
||||
import { PtyManager } from './pty/index.js';
|
||||
import { createAuthRoutes } from './routes/auth.js';
|
||||
import { createFileRoutes } from './routes/files.js';
|
||||
import { createFilesystemRoutes } from './routes/filesystem.js';
|
||||
import { createLogRoutes } from './routes/logs.js';
|
||||
import { createPushRoutes } from './routes/push.js';
|
||||
|
|
@ -568,6 +569,10 @@ export async function createApp(): Promise<AppInstance> {
|
|||
app.use('/api', createLogRoutes());
|
||||
logger.debug('Mounted log routes');
|
||||
|
||||
// Mount file routes
|
||||
app.use('/api', createFileRoutes());
|
||||
logger.debug('Mounted file routes');
|
||||
|
||||
// Mount push notification routes
|
||||
if (vapidManager) {
|
||||
app.use(
|
||||
|
|
|
|||
239
web/src/test/integration/file-upload.test.ts
Normal file
239
web/src/test/integration/file-upload.test.ts
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
/**
|
||||
* Integration tests for file upload functionality
|
||||
*/
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { createBasicAuthHeader, ServerManager } from '../utils/server-utils.js';
|
||||
|
||||
describe('File Upload API', () => {
|
||||
const serverManager = new ServerManager();
|
||||
let baseUrl: string;
|
||||
const authHeader = createBasicAuthHeader('testuser', 'testpass');
|
||||
|
||||
beforeAll(async () => {
|
||||
// Start test server with authentication disabled for testing file functionality
|
||||
const server = await serverManager.startServer({
|
||||
args: ['--port', '0', '--no-auth'],
|
||||
serverType: 'FILE_UPLOAD_TEST',
|
||||
});
|
||||
|
||||
baseUrl = `http://localhost:${server.port}`;
|
||||
|
||||
// Wait for server to be ready
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await serverManager.cleanup();
|
||||
});
|
||||
|
||||
it('should upload a file successfully', async () => {
|
||||
// Create a simple test text file
|
||||
const testFileContent = 'Hello, world!';
|
||||
|
||||
const formData = new FormData();
|
||||
const blob = new Blob([testFileContent], { type: 'text/plain' });
|
||||
formData.append('file', blob, 'test.txt');
|
||||
|
||||
const response = await fetch(`${baseUrl}/api/files/upload`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: authHeader,
|
||||
},
|
||||
body: formData,
|
||||
});
|
||||
|
||||
expect(response.ok).toBe(true);
|
||||
const result = await response.json();
|
||||
|
||||
expect(result).toMatchObject({
|
||||
success: true,
|
||||
filename: expect.stringMatching(/^[a-f0-9-]+\.txt$/),
|
||||
originalName: 'test.txt',
|
||||
size: testFileContent.length,
|
||||
mimetype: 'text/plain',
|
||||
path: expect.stringContaining('uploads'),
|
||||
relativePath: expect.stringContaining('uploads'),
|
||||
});
|
||||
});
|
||||
|
||||
it('should accept text files', async () => {
|
||||
const textBuffer = Buffer.from('This is a text file', 'utf-8');
|
||||
const formData = new FormData();
|
||||
const blob = new Blob([textBuffer], { type: 'text/plain' });
|
||||
formData.append('file', blob, 'test.txt');
|
||||
|
||||
const response = await fetch(`${baseUrl}/api/files/upload`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: authHeader,
|
||||
},
|
||||
body: formData,
|
||||
});
|
||||
|
||||
expect(response.ok).toBe(true);
|
||||
});
|
||||
|
||||
it.skip('should require authentication', async () => {
|
||||
// This test is skipped because we're running with --no-auth for integration testing
|
||||
const testFileBuffer = Buffer.from('test content');
|
||||
|
||||
const formData = new FormData();
|
||||
const blob = new Blob([testFileBuffer], { type: 'text/plain' });
|
||||
formData.append('file', blob, 'test.txt');
|
||||
|
||||
const response = await fetch(`${baseUrl}/api/files/upload`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
it('should return 400 when no file is provided', async () => {
|
||||
const formData = new FormData();
|
||||
|
||||
const response = await fetch(`${baseUrl}/api/files/upload`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: authHeader,
|
||||
},
|
||||
body: formData,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
const result = await response.json();
|
||||
expect(result.error).toBe('No file provided');
|
||||
});
|
||||
|
||||
it('should list uploaded files', async () => {
|
||||
const response = await fetch(`${baseUrl}/api/files`, {
|
||||
headers: {
|
||||
Authorization: authHeader,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.ok).toBe(true);
|
||||
const result = await response.json();
|
||||
|
||||
expect(result).toHaveProperty('files');
|
||||
expect(result).toHaveProperty('count');
|
||||
expect(Array.isArray(result.files)).toBe(true);
|
||||
expect(typeof result.count).toBe('number');
|
||||
|
||||
if (result.files.length > 0) {
|
||||
const file = result.files[0];
|
||||
expect(file).toHaveProperty('filename');
|
||||
expect(file).toHaveProperty('size');
|
||||
expect(file).toHaveProperty('createdAt');
|
||||
expect(file).toHaveProperty('modifiedAt');
|
||||
expect(file).toHaveProperty('url');
|
||||
expect(file).toHaveProperty('extension');
|
||||
}
|
||||
});
|
||||
|
||||
it('should serve uploaded files', async () => {
|
||||
// First upload a file
|
||||
const testFileContent = 'Test file content for serving';
|
||||
|
||||
const formData = new FormData();
|
||||
const blob = new Blob([testFileContent], { type: 'text/plain' });
|
||||
formData.append('file', blob, 'serve-test.txt');
|
||||
|
||||
const uploadResponse = await fetch(`${baseUrl}/api/files/upload`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: authHeader,
|
||||
},
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const uploadResult = await uploadResponse.json();
|
||||
const filename = uploadResult.filename;
|
||||
|
||||
// Now try to serve the file
|
||||
const serveResponse = await fetch(`${baseUrl}/api/files/${filename}`);
|
||||
|
||||
expect(serveResponse.ok).toBe(true);
|
||||
expect(serveResponse.headers.get('content-type')).toBe('text/plain');
|
||||
|
||||
const fileText = await serveResponse.text();
|
||||
expect(fileText).toBe(testFileContent);
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent files', async () => {
|
||||
const response = await fetch(`${baseUrl}/api/files/non-existent.txt`);
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
|
||||
it('should prevent path traversal attacks', async () => {
|
||||
const response = await fetch(`${baseUrl}/api/files/..%2F..%2F..%2Fetc%2Fpasswd`);
|
||||
expect(response.status).toBe(400);
|
||||
|
||||
const result = await response.json();
|
||||
expect(result.error).toBe('Invalid filename');
|
||||
});
|
||||
|
||||
it('should delete uploaded files', async () => {
|
||||
// First upload a file
|
||||
const testFileContent = 'File to be deleted';
|
||||
const formData = new FormData();
|
||||
const blob = new Blob([testFileContent], { type: 'text/plain' });
|
||||
formData.append('file', blob, 'delete-test.txt');
|
||||
|
||||
const uploadResponse = await fetch(`${baseUrl}/api/files/upload`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: authHeader,
|
||||
},
|
||||
body: formData,
|
||||
});
|
||||
|
||||
expect(uploadResponse.ok).toBe(true);
|
||||
const uploadResult = await uploadResponse.json();
|
||||
const filename = uploadResult.filename;
|
||||
|
||||
// Now delete the file
|
||||
const deleteResponse = await fetch(`${baseUrl}/api/files/${filename}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Authorization: authHeader,
|
||||
},
|
||||
});
|
||||
|
||||
expect(deleteResponse.ok).toBe(true);
|
||||
const deleteResult = await deleteResponse.json();
|
||||
expect(deleteResult.success).toBe(true);
|
||||
expect(deleteResult.message).toBe('File deleted successfully');
|
||||
|
||||
// Verify file is gone
|
||||
const checkResponse = await fetch(`${baseUrl}/api/files/${filename}`);
|
||||
expect(checkResponse.status).toBe(404);
|
||||
});
|
||||
|
||||
it('should return 404 when deleting non-existent file', async () => {
|
||||
const response = await fetch(`${baseUrl}/api/files/non-existent-file.txt`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Authorization: authHeader,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
const result = await response.json();
|
||||
expect(result.error).toBe('File not found');
|
||||
});
|
||||
|
||||
it('should prevent path traversal in DELETE', async () => {
|
||||
const response = await fetch(`${baseUrl}/api/files/..%2F..%2F..%2Fetc%2Fpasswd`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Authorization: authHeader,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
const result = await response.json();
|
||||
expect(result.error).toBe('Invalid filename');
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue