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:
Helmut Januschka 2025-07-01 07:47:08 +02:00 committed by GitHub
parent 1d9112b9d3
commit 8d85c26a84
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 1740 additions and 19 deletions

View file

@ -1,8 +1,9 @@
{
"permissions": {
"allow": [
"Bash(pnpm test:*)"
"Bash(pnpm test:*)",
"Bash(rg:*)"
],
"deny": []
}
}
}

View file

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

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

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

View file

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

View file

@ -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: {}

View 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();
});
});

View 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>
`;
}
}

View 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));
});
});
});

View file

@ -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>
`;
}

View file

@ -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?.()}

View 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;
}

View file

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

View 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');
});
});