mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-07-01 01:23:06 +08:00
Compare commits
8 Commits
fix/mobile
...
fix/video-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d5ed6fdd8 | ||
|
|
dc3a928bed | ||
|
|
6b5506087c | ||
|
|
de13eed72a | ||
|
|
e39de299c3 | ||
|
|
92b5b935fd | ||
|
|
1df336ca2d | ||
|
|
ed4ae3114a |
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<link rel="icon" type="image/png" href="/favicon.png" />
|
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no, viewport-fit=cover, interactive-widget=resizes-content" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||||
<title>CloudCLI UI</title>
|
<title>CloudCLI UI</title>
|
||||||
|
|
||||||
<!-- PWA Manifest -->
|
<!-- PWA Manifest -->
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import React from 'react';
|
||||||
|
import { renderToStaticMarkup } from 'react-dom/server';
|
||||||
|
import { QuestionAnswerContent } from './QuestionAnswerContent';
|
||||||
|
|
||||||
|
// Regression coverage for the chat-interface crash where an AskUserQuestion
|
||||||
|
// payload loaded from a session transcript arrives with a non-array `questions`
|
||||||
|
// or a question missing its `options` array. Rendering must degrade gracefully
|
||||||
|
// instead of throwing "TypeError: e.map is not a function".
|
||||||
|
|
||||||
|
test('renders without throwing when questions is a non-array value', () => {
|
||||||
|
assert.doesNotThrow(() => {
|
||||||
|
renderToStaticMarkup(
|
||||||
|
React.createElement(QuestionAnswerContent, {
|
||||||
|
// Malformed: object instead of an array
|
||||||
|
questions: { 0: { question: 'q?', options: [{ label: 'a' }] } } as never,
|
||||||
|
answers: {},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders without throwing when a question is missing options[]', () => {
|
||||||
|
assert.doesNotThrow(() => {
|
||||||
|
renderToStaticMarkup(
|
||||||
|
React.createElement(QuestionAnswerContent, {
|
||||||
|
questions: [{ question: 'Pick one?', header: 'H' } as never],
|
||||||
|
answers: { 'Pick one?': 'X' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders without throwing when options[] contains malformed entries', () => {
|
||||||
|
assert.doesNotThrow(() => {
|
||||||
|
renderToStaticMarkup(
|
||||||
|
React.createElement(QuestionAnswerContent, {
|
||||||
|
questions: [{ question: 'Pick one?', options: [null, 'oops', { label: 'A' }] } as never],
|
||||||
|
answers: { 'Pick one?': 'A, Custom' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders without throwing when a questions entry is null/non-object', () => {
|
||||||
|
assert.doesNotThrow(() => {
|
||||||
|
renderToStaticMarkup(
|
||||||
|
React.createElement(QuestionAnswerContent, {
|
||||||
|
questions: [null, 'oops', { question: 'Ok?', options: [{ label: 'A' }] }] as never,
|
||||||
|
answers: {},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders without throwing when an answer is a non-string value', () => {
|
||||||
|
assert.doesNotThrow(() => {
|
||||||
|
renderToStaticMarkup(
|
||||||
|
React.createElement(QuestionAnswerContent, {
|
||||||
|
questions: [{ question: 'Pick one?', options: [{ label: 'A' }] }],
|
||||||
|
// Malformed: answer is an object instead of the expected string
|
||||||
|
answers: { 'Pick one?': { unexpected: true } } as never,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('still renders a well-formed question + answer', () => {
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
React.createElement(QuestionAnswerContent, {
|
||||||
|
questions: [{ question: 'Pick one?', header: 'H', options: [{ label: 'A' }, { label: 'B' }] }],
|
||||||
|
answers: { 'Pick one?': 'A' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
assert.ok(html.includes('Pick one?'));
|
||||||
|
});
|
||||||
@@ -15,7 +15,11 @@ export const QuestionAnswerContent: React.FC<QuestionAnswerContentProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const [expandedIdx, setExpandedIdx] = useState<number | null>(null);
|
const [expandedIdx, setExpandedIdx] = useState<number | null>(null);
|
||||||
|
|
||||||
if (!questions || questions.length === 0) {
|
// Tool inputs are runtime data loaded from session transcripts and may be
|
||||||
|
// malformed (e.g. `questions` arriving as a non-array). Guard with
|
||||||
|
// Array.isArray so a single bad payload can't crash the whole chat view
|
||||||
|
// with "e.map is not a function".
|
||||||
|
if (!Array.isArray(questions) || questions.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,11 +28,23 @@ export const QuestionAnswerContent: React.FC<QuestionAnswerContentProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`space-y-2 ${className}`}>
|
<div className={`space-y-2 ${className}`}>
|
||||||
{questions.map((q, idx) => {
|
{questions.map((rawQuestion, idx) => {
|
||||||
|
// Entries come from session transcripts and may be malformed; skip
|
||||||
|
// anything that isn't a proper question object with a string prompt.
|
||||||
|
if (!rawQuestion || typeof rawQuestion !== 'object' || typeof rawQuestion.question !== 'string') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const q = rawQuestion;
|
||||||
const answer = answers?.[q.question];
|
const answer = answers?.[q.question];
|
||||||
const answerLabels = answer ? answer.split(', ') : [];
|
// `answer` may be a non-string (or absent) in malformed payloads.
|
||||||
|
const answerLabels = typeof answer === 'string' ? answer.split(', ') : [];
|
||||||
const skipped = !answer;
|
const skipped = !answer;
|
||||||
const isExpanded = expandedIdx === idx;
|
const isExpanded = expandedIdx === idx;
|
||||||
|
// `options` is typed as an array but comes from untrusted runtime data;
|
||||||
|
// keep only valid entries so `.some`/`.map` below never throw.
|
||||||
|
const options = Array.isArray(q.options)
|
||||||
|
? q.options.filter((opt) => opt && typeof opt === 'object' && typeof opt.label === 'string')
|
||||||
|
: [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -74,7 +90,7 @@ export const QuestionAnswerContent: React.FC<QuestionAnswerContentProps> = ({
|
|||||||
{!isExpanded && answerLabels.length > 0 && (
|
{!isExpanded && answerLabels.length > 0 && (
|
||||||
<div className="mt-1.5 flex flex-wrap gap-1">
|
<div className="mt-1.5 flex flex-wrap gap-1">
|
||||||
{answerLabels.map((lbl) => {
|
{answerLabels.map((lbl) => {
|
||||||
const isCustom = !q.options.some(o => o.label === lbl);
|
const isCustom = !options.some(o => o.label === lbl);
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
key={lbl}
|
key={lbl}
|
||||||
@@ -110,7 +126,7 @@ export const QuestionAnswerContent: React.FC<QuestionAnswerContentProps> = ({
|
|||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
<div className="border-t border-gray-100 px-3 pb-2.5 pt-0.5 dark:border-gray-700/40">
|
<div className="border-t border-gray-100 px-3 pb-2.5 pt-0.5 dark:border-gray-700/40">
|
||||||
<div className="ml-6.5 space-y-1">
|
<div className="ml-6.5 space-y-1">
|
||||||
{q.options.map((opt) => {
|
{options.map((opt) => {
|
||||||
const wasSelected = answerLabels.includes(opt.label);
|
const wasSelected = answerLabels.includes(opt.label);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -148,7 +164,7 @@ export const QuestionAnswerContent: React.FC<QuestionAnswerContentProps> = ({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{answerLabels.filter(lbl => !q.options.some(o => o.label === lbl)).map(lbl => (
|
{answerLabels.filter(lbl => !options.some(o => o.label === lbl)).map(lbl => (
|
||||||
<div
|
<div
|
||||||
key={lbl}
|
key={lbl}
|
||||||
className="flex items-start gap-2 rounded-lg border border-blue-200/60 bg-blue-50/80 px-2.5 py-1.5 text-[12px] dark:border-blue-800/40 dark:bg-blue-900/20"
|
className="flex items-start gap-2 rounded-lg border border-blue-200/60 bg-blue-50/80 px-2.5 py-1.5 text-[12px] dark:border-blue-800/40 dark:bg-blue-900/20"
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'react';
|
|||||||
import { api } from '../../../utils/api';
|
import { api } from '../../../utils/api';
|
||||||
import type { CodeEditorFile } from '../types/types';
|
import type { CodeEditorFile } from '../types/types';
|
||||||
import { isBinaryFile } from '../utils/binaryFile';
|
import { isBinaryFile } from '../utils/binaryFile';
|
||||||
|
import { getPreviewKind } from '../utils/previewableFile';
|
||||||
|
|
||||||
type UseCodeEditorDocumentParams = {
|
type UseCodeEditorDocumentParams = {
|
||||||
file: CodeEditorFile;
|
file: CodeEditorFile;
|
||||||
@@ -23,6 +24,9 @@ export const useCodeEditorDocument = ({ file, projectPath }: UseCodeEditorDocume
|
|||||||
const [saveSuccess, setSaveSuccess] = useState(false);
|
const [saveSuccess, setSaveSuccess] = useState(false);
|
||||||
const [saveError, setSaveError] = useState<string | null>(null);
|
const [saveError, setSaveError] = useState<string | null>(null);
|
||||||
const [isBinary, setIsBinary] = useState(false);
|
const [isBinary, setIsBinary] = useState(false);
|
||||||
|
// Some binaries (images, PDFs, audio, video) can be rendered natively, so the
|
||||||
|
// editor shows an inline preview instead of the generic binary placeholder.
|
||||||
|
const previewKind = getPreviewKind(file.name);
|
||||||
// `fileProjectId` is the DB primary key passed down from the editor sidebar;
|
// `fileProjectId` is the DB primary key passed down from the editor sidebar;
|
||||||
// the fallback to `projectPath` preserves older callers that didn't yet
|
// the fallback to `projectPath` preserves older callers that didn't yet
|
||||||
// propagate the identifier.
|
// propagate the identifier.
|
||||||
@@ -38,8 +42,19 @@ export const useCodeEditorDocument = ({ file, projectPath }: UseCodeEditorDocume
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setIsBinary(false);
|
setIsBinary(false);
|
||||||
|
|
||||||
|
// Natively previewable media (image/pdf/audio/video) is rendered by
|
||||||
|
// CodeEditorMediaPreview, so there is nothing to read as text here.
|
||||||
|
// Clear any buffer left over from a previously opened text file so a
|
||||||
|
// stray save can't write stale content over the binary file.
|
||||||
|
if (getPreviewKind(file.name)) {
|
||||||
|
setContent('');
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Check if file is binary by extension
|
// Check if file is binary by extension
|
||||||
if (isBinaryFile(file.name)) {
|
if (isBinaryFile(file.name)) {
|
||||||
|
setContent('');
|
||||||
setIsBinary(true);
|
setIsBinary(true);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
@@ -76,6 +91,12 @@ export const useCodeEditorDocument = ({ file, projectPath }: UseCodeEditorDocume
|
|||||||
}, [file.diffInfo, file.name, fileDiffNewString, fileDiffOldString, fileName, filePath, fileProjectId]);
|
}, [file.diffInfo, file.name, fileDiffNewString, fileDiffOldString, fileName, filePath, fileProjectId]);
|
||||||
|
|
||||||
const handleSave = useCallback(async () => {
|
const handleSave = useCallback(async () => {
|
||||||
|
// Preview-only and binary files have no editable text buffer; never write
|
||||||
|
// them back (e.g. via Cmd/Ctrl+S) or we'd corrupt the file on disk.
|
||||||
|
if (previewKind || isBinaryFile(fileName)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setSaveError(null);
|
setSaveError(null);
|
||||||
|
|
||||||
@@ -109,7 +130,7 @@ export const useCodeEditorDocument = ({ file, projectPath }: UseCodeEditorDocume
|
|||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
}, [content, filePath, fileProjectId]);
|
}, [content, filePath, fileProjectId, previewKind, fileName]);
|
||||||
|
|
||||||
const handleDownload = useCallback(() => {
|
const handleDownload = useCallback(() => {
|
||||||
const blob = new Blob([content], { type: 'text/plain' });
|
const blob = new Blob([content], { type: 'text/plain' });
|
||||||
@@ -134,6 +155,8 @@ export const useCodeEditorDocument = ({ file, projectPath }: UseCodeEditorDocume
|
|||||||
saveSuccess,
|
saveSuccess,
|
||||||
saveError,
|
saveError,
|
||||||
isBinary,
|
isBinary,
|
||||||
|
previewKind,
|
||||||
|
fileProjectId,
|
||||||
handleSave,
|
handleSave,
|
||||||
handleDownload,
|
handleDownload,
|
||||||
};
|
};
|
||||||
|
|||||||
63
src/components/code-editor/utils/previewableFile.ts
Normal file
63
src/components/code-editor/utils/previewableFile.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
// Some binary files can't be edited as text, but the browser can still render
|
||||||
|
// them natively (images, PDFs, audio, video). For those we show an inline
|
||||||
|
// preview instead of the generic "binary file" placeholder. Anything not listed
|
||||||
|
// here (zip, exe, avi, mkv, fonts, ...) falls through to the binary message.
|
||||||
|
|
||||||
|
export type PreviewKind = 'image' | 'pdf' | 'video' | 'audio';
|
||||||
|
|
||||||
|
// Single source of truth: every extension the browser can preview, mapped to the
|
||||||
|
// MIME type we apply when the server response has a missing/generic Content-Type.
|
||||||
|
// The preview kind is derived from the MIME type so the two never drift apart.
|
||||||
|
// Formats browsers generally can't play (avi, mkv, flv, wmv) are intentionally
|
||||||
|
// absent and keep the binary fallback.
|
||||||
|
const EXTENSION_MIME: Record<string, string> = {
|
||||||
|
// Images
|
||||||
|
png: 'image/png',
|
||||||
|
jpg: 'image/jpeg',
|
||||||
|
jpeg: 'image/jpeg',
|
||||||
|
gif: 'image/gif',
|
||||||
|
svg: 'image/svg+xml',
|
||||||
|
webp: 'image/webp',
|
||||||
|
ico: 'image/x-icon',
|
||||||
|
bmp: 'image/bmp',
|
||||||
|
avif: 'image/avif',
|
||||||
|
apng: 'image/apng',
|
||||||
|
// PDF
|
||||||
|
pdf: 'application/pdf',
|
||||||
|
// Video
|
||||||
|
mp4: 'video/mp4',
|
||||||
|
webm: 'video/webm',
|
||||||
|
ogv: 'video/ogg',
|
||||||
|
mov: 'video/quicktime',
|
||||||
|
m4v: 'video/x-m4v',
|
||||||
|
// Audio
|
||||||
|
mp3: 'audio/mpeg',
|
||||||
|
wav: 'audio/wav',
|
||||||
|
m4a: 'audio/mp4',
|
||||||
|
aac: 'audio/aac',
|
||||||
|
flac: 'audio/flac',
|
||||||
|
opus: 'audio/opus',
|
||||||
|
oga: 'audio/ogg',
|
||||||
|
ogg: 'audio/ogg',
|
||||||
|
weba: 'audio/webm',
|
||||||
|
};
|
||||||
|
|
||||||
|
const extensionOf = (filename: string): string => filename.split('.').pop()?.toLowerCase() ?? '';
|
||||||
|
|
||||||
|
const kindForMime = (mime: string): PreviewKind | null => {
|
||||||
|
if (mime === 'application/pdf') return 'pdf';
|
||||||
|
if (mime.startsWith('image/')) return 'image';
|
||||||
|
if (mime.startsWith('video/')) return 'video';
|
||||||
|
if (mime.startsWith('audio/')) return 'audio';
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getPreviewKind = (filename: string): PreviewKind | null => {
|
||||||
|
const mime = EXTENSION_MIME[extensionOf(filename)];
|
||||||
|
return mime ? kindForMime(mime) : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// MIME type to fall back to when the server returns no/generic Content-Type.
|
||||||
|
// Returns undefined for non-previewable extensions.
|
||||||
|
export const getPreviewMimeType = (filename: string): string | undefined =>
|
||||||
|
EXTENSION_MIME[extensionOf(filename)];
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import { EditorView } from '@codemirror/view';
|
import { EditorView } from '@codemirror/view';
|
||||||
import { unifiedMergeView } from '@codemirror/merge';
|
import { unifiedMergeView } from '@codemirror/merge';
|
||||||
import type { Extension } from '@codemirror/state';
|
import type { Extension } from '@codemirror/state';
|
||||||
import { useMemo, useState } from 'react';
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { usePaletteOps } from '../../../contexts/PaletteOpsContext';
|
import { usePaletteOps } from '../../../contexts/PaletteOpsContext';
|
||||||
import { useCodeEditorDocument } from '../hooks/useCodeEditorDocument';
|
import { useCodeEditorDocument } from '../hooks/useCodeEditorDocument';
|
||||||
import { useCodeEditorSettings } from '../hooks/useCodeEditorSettings';
|
import { useCodeEditorSettings } from '../hooks/useCodeEditorSettings';
|
||||||
@@ -11,11 +12,13 @@ import type { CodeEditorFile } from '../types/types';
|
|||||||
import { createMinimapExtension, createScrollToFirstChunkExtension, getLanguageExtensions } from '../utils/editorExtensions';
|
import { createMinimapExtension, createScrollToFirstChunkExtension, getLanguageExtensions } from '../utils/editorExtensions';
|
||||||
import { getEditorStyles } from '../utils/editorStyles';
|
import { getEditorStyles } from '../utils/editorStyles';
|
||||||
import { createEditorToolbarPanelExtension } from '../utils/editorToolbarPanel';
|
import { createEditorToolbarPanelExtension } from '../utils/editorToolbarPanel';
|
||||||
|
|
||||||
import CodeEditorFooter from './subcomponents/CodeEditorFooter';
|
import CodeEditorFooter from './subcomponents/CodeEditorFooter';
|
||||||
import CodeEditorHeader from './subcomponents/CodeEditorHeader';
|
import CodeEditorHeader from './subcomponents/CodeEditorHeader';
|
||||||
import CodeEditorLoadingState from './subcomponents/CodeEditorLoadingState';
|
import CodeEditorLoadingState from './subcomponents/CodeEditorLoadingState';
|
||||||
import CodeEditorSurface from './subcomponents/CodeEditorSurface';
|
import CodeEditorSurface from './subcomponents/CodeEditorSurface';
|
||||||
import CodeEditorBinaryFile from './subcomponents/CodeEditorBinaryFile';
|
import CodeEditorBinaryFile from './subcomponents/CodeEditorBinaryFile';
|
||||||
|
import CodeEditorMediaPreview from './subcomponents/CodeEditorMediaPreview';
|
||||||
|
|
||||||
type CodeEditorProps = {
|
type CodeEditorProps = {
|
||||||
file: CodeEditorFile;
|
file: CodeEditorFile;
|
||||||
@@ -58,6 +61,8 @@ export default function CodeEditor({
|
|||||||
saveSuccess,
|
saveSuccess,
|
||||||
saveError,
|
saveError,
|
||||||
isBinary,
|
isBinary,
|
||||||
|
previewKind,
|
||||||
|
fileProjectId,
|
||||||
handleSave,
|
handleSave,
|
||||||
handleDownload,
|
handleDownload,
|
||||||
} = useCodeEditorDocument({
|
} = useCodeEditorDocument({
|
||||||
@@ -70,6 +75,29 @@ export default function CodeEditor({
|
|||||||
return extension === 'md' || extension === 'markdown';
|
return extension === 'md' || extension === 'markdown';
|
||||||
}, [file.name]);
|
}, [file.name]);
|
||||||
|
|
||||||
|
const isHtmlPreviewFile = useMemo(() => {
|
||||||
|
const extension = file.name.split('.').pop()?.toLowerCase();
|
||||||
|
return extension === 'html' || extension === 'htm';
|
||||||
|
}, [file.name]);
|
||||||
|
|
||||||
|
const openHtmlPreview = useCallback(() => {
|
||||||
|
const previewWindow = window.open('', '_blank');
|
||||||
|
if (!previewWindow) return;
|
||||||
|
|
||||||
|
previewWindow.opener = null;
|
||||||
|
previewWindow.document.title = file.name;
|
||||||
|
previewWindow.document.body.style.margin = '0';
|
||||||
|
|
||||||
|
const iframe = previewWindow.document.createElement('iframe');
|
||||||
|
iframe.title = file.name;
|
||||||
|
iframe.sandbox.add('allow-forms', 'allow-modals', 'allow-popups', 'allow-scripts');
|
||||||
|
iframe.style.cssText = 'position:fixed;inset:0;width:100%;height:100%;border:0;background:white';
|
||||||
|
|
||||||
|
iframe.srcdoc = content;
|
||||||
|
|
||||||
|
previewWindow.document.body.appendChild(iframe);
|
||||||
|
}, [content, file.name]);
|
||||||
|
|
||||||
const minimapExtension = useMemo(
|
const minimapExtension = useMemo(
|
||||||
() => (
|
() => (
|
||||||
createMinimapExtension({
|
createMinimapExtension({
|
||||||
@@ -162,6 +190,30 @@ export default function CodeEditor({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Natively previewable media (image/pdf/audio/video) is rendered inline
|
||||||
|
// instead of showing the generic "cannot be displayed" placeholder.
|
||||||
|
if (previewKind) {
|
||||||
|
return (
|
||||||
|
<CodeEditorMediaPreview
|
||||||
|
file={file}
|
||||||
|
kind={previewKind}
|
||||||
|
projectId={fileProjectId}
|
||||||
|
isSidebar={isSidebar}
|
||||||
|
isFullscreen={isFullscreen}
|
||||||
|
onClose={onClose}
|
||||||
|
onToggleFullscreen={() => setIsFullscreen((previous) => !previous)}
|
||||||
|
labels={{
|
||||||
|
loading: t('filePreview.loading', 'Loading preview...'),
|
||||||
|
error: t('filePreview.error', 'Unable to display this file.'),
|
||||||
|
openInNewTab: t('filePreview.openInNewTab', 'Open in new tab'),
|
||||||
|
fullscreen: t('actions.fullscreen', 'Fullscreen'),
|
||||||
|
exitFullscreen: t('actions.exitFullscreen', 'Exit fullscreen'),
|
||||||
|
close: t('actions.close', 'Close'),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Binary file display
|
// Binary file display
|
||||||
if (isBinary) {
|
if (isBinary) {
|
||||||
return (
|
return (
|
||||||
@@ -197,10 +249,12 @@ export default function CodeEditor({
|
|||||||
isSidebar={isSidebar}
|
isSidebar={isSidebar}
|
||||||
isFullscreen={isFullscreen}
|
isFullscreen={isFullscreen}
|
||||||
isMarkdownFile={isMarkdownFile}
|
isMarkdownFile={isMarkdownFile}
|
||||||
|
isHtmlPreviewFile={isHtmlPreviewFile}
|
||||||
markdownPreview={markdownPreview}
|
markdownPreview={markdownPreview}
|
||||||
saving={saving}
|
saving={saving}
|
||||||
saveSuccess={saveSuccess}
|
saveSuccess={saveSuccess}
|
||||||
onToggleMarkdownPreview={() => setMarkdownPreview((previous) => !previous)}
|
onToggleMarkdownPreview={() => setMarkdownPreview((previous) => !previous)}
|
||||||
|
onOpenHtmlPreview={openHtmlPreview}
|
||||||
onOpenSettings={() => paletteOps.openSettings('appearance')}
|
onOpenSettings={() => paletteOps.openSettings('appearance')}
|
||||||
onDownload={handleDownload}
|
onDownload={handleDownload}
|
||||||
onSave={handleSave}
|
onSave={handleSave}
|
||||||
@@ -210,6 +264,7 @@ export default function CodeEditor({
|
|||||||
showingChanges: t('header.showingChanges'),
|
showingChanges: t('header.showingChanges'),
|
||||||
editMarkdown: t('actions.editMarkdown'),
|
editMarkdown: t('actions.editMarkdown'),
|
||||||
previewMarkdown: t('actions.previewMarkdown'),
|
previewMarkdown: t('actions.previewMarkdown'),
|
||||||
|
previewHtml: t('actions.previewHtml', 'Open HTML preview in new tab'),
|
||||||
settings: t('toolbar.settings'),
|
settings: t('toolbar.settings'),
|
||||||
download: t('actions.download'),
|
download: t('actions.download'),
|
||||||
save: t('actions.save'),
|
save: t('actions.save'),
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Code2, Download, Eye, Maximize2, Minimize2, Save, Settings as SettingsIcon, X } from 'lucide-react';
|
import { Code2, Download, Eye, Maximize2, Minimize2, Save, Settings as SettingsIcon, X } from 'lucide-react';
|
||||||
|
|
||||||
import type { CodeEditorFile } from '../../types/types';
|
import type { CodeEditorFile } from '../../types/types';
|
||||||
|
|
||||||
type CodeEditorHeaderProps = {
|
type CodeEditorHeaderProps = {
|
||||||
@@ -6,10 +7,12 @@ type CodeEditorHeaderProps = {
|
|||||||
isSidebar: boolean;
|
isSidebar: boolean;
|
||||||
isFullscreen: boolean;
|
isFullscreen: boolean;
|
||||||
isMarkdownFile: boolean;
|
isMarkdownFile: boolean;
|
||||||
|
isHtmlPreviewFile: boolean;
|
||||||
markdownPreview: boolean;
|
markdownPreview: boolean;
|
||||||
saving: boolean;
|
saving: boolean;
|
||||||
saveSuccess: boolean;
|
saveSuccess: boolean;
|
||||||
onToggleMarkdownPreview: () => void;
|
onToggleMarkdownPreview: () => void;
|
||||||
|
onOpenHtmlPreview: () => void;
|
||||||
onOpenSettings: () => void;
|
onOpenSettings: () => void;
|
||||||
onDownload: () => void;
|
onDownload: () => void;
|
||||||
onSave: () => void;
|
onSave: () => void;
|
||||||
@@ -19,6 +22,7 @@ type CodeEditorHeaderProps = {
|
|||||||
showingChanges: string;
|
showingChanges: string;
|
||||||
editMarkdown: string;
|
editMarkdown: string;
|
||||||
previewMarkdown: string;
|
previewMarkdown: string;
|
||||||
|
previewHtml: string;
|
||||||
settings: string;
|
settings: string;
|
||||||
download: string;
|
download: string;
|
||||||
save: string;
|
save: string;
|
||||||
@@ -35,10 +39,12 @@ export default function CodeEditorHeader({
|
|||||||
isSidebar,
|
isSidebar,
|
||||||
isFullscreen,
|
isFullscreen,
|
||||||
isMarkdownFile,
|
isMarkdownFile,
|
||||||
|
isHtmlPreviewFile,
|
||||||
markdownPreview,
|
markdownPreview,
|
||||||
saving,
|
saving,
|
||||||
saveSuccess,
|
saveSuccess,
|
||||||
onToggleMarkdownPreview,
|
onToggleMarkdownPreview,
|
||||||
|
onOpenHtmlPreview,
|
||||||
onOpenSettings,
|
onOpenSettings,
|
||||||
onDownload,
|
onDownload,
|
||||||
onSave,
|
onSave,
|
||||||
@@ -82,6 +88,17 @@ export default function CodeEditorHeader({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{isHtmlPreviewFile && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onOpenHtmlPreview}
|
||||||
|
className="flex items-center justify-center rounded-md p-1.5 text-gray-600 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white"
|
||||||
|
title={labels.previewHtml}
|
||||||
|
>
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onOpenSettings}
|
onClick={onOpenSettings}
|
||||||
|
|||||||
@@ -0,0 +1,289 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { authenticatedFetch } from '../../../../utils/api';
|
||||||
|
import type { CodeEditorFile } from '../../types/types';
|
||||||
|
import { getPreviewMimeType, type PreviewKind } from '../../utils/previewableFile';
|
||||||
|
|
||||||
|
type CodeEditorMediaPreviewProps = {
|
||||||
|
file: CodeEditorFile;
|
||||||
|
kind: PreviewKind;
|
||||||
|
// DB projectId used to build the raw-content URL; falls back to projectPath
|
||||||
|
// for older callers, mirroring useCodeEditorDocument.
|
||||||
|
projectId?: string;
|
||||||
|
isSidebar: boolean;
|
||||||
|
isFullscreen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onToggleFullscreen: () => void;
|
||||||
|
labels: {
|
||||||
|
loading: string;
|
||||||
|
error: string;
|
||||||
|
openInNewTab: string;
|
||||||
|
fullscreen: string;
|
||||||
|
exitFullscreen: string;
|
||||||
|
close: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Reject a "PDF" whose bytes aren't actually a PDF before handing it to the
|
||||||
|
// same-origin iframe, so a mislabeled HTML/SVG file can't run in the app origin.
|
||||||
|
const PDF_HEADER_SCAN_BYTES = 1024;
|
||||||
|
|
||||||
|
const looksLikePdf = async (blob: Blob): Promise<boolean> => {
|
||||||
|
const header = await blob.slice(0, PDF_HEADER_SCAN_BYTES).arrayBuffer();
|
||||||
|
// PDFs must contain the "%PDF-" marker at the very start of the file.
|
||||||
|
return new TextDecoder('latin1').decode(header).includes('%PDF-');
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function CodeEditorMediaPreview({
|
||||||
|
file,
|
||||||
|
kind,
|
||||||
|
projectId,
|
||||||
|
isSidebar,
|
||||||
|
isFullscreen,
|
||||||
|
onClose,
|
||||||
|
onToggleFullscreen,
|
||||||
|
labels,
|
||||||
|
}: CodeEditorMediaPreviewProps) {
|
||||||
|
const [url, setUrl] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
// Identifies which file the current `url` was loaded for. Rendering is gated on
|
||||||
|
// this so a blob from a previously-opened file can never show under the new
|
||||||
|
// file (the editor reuses this component instance across files).
|
||||||
|
const [loadedKey, setLoadedKey] = useState<string | null>(null);
|
||||||
|
const sourceKey = `${projectId ?? ''}:${file.path}:${kind}`;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!projectId) {
|
||||||
|
setUrl(null);
|
||||||
|
setLoadedKey(null);
|
||||||
|
setError(labels.error);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let objectUrl: string | null = null;
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
const loadMedia = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
setUrl(null);
|
||||||
|
|
||||||
|
// The content endpoint requires the auth header, so we fetch the bytes
|
||||||
|
// ourselves and hand the media element a blob URL instead of a bare src.
|
||||||
|
// Fetching a blob (rather than streaming) also lets <video>/<audio> seek.
|
||||||
|
const contentUrl = `/api/projects/${projectId}/files/content?path=${encodeURIComponent(file.path)}`;
|
||||||
|
const response = await authenticatedFetch(contentUrl, { signal: controller.signal });
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Request failed with status ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await response.blob();
|
||||||
|
|
||||||
|
// Pick the MIME type to expose to the browser. Preserve a valid
|
||||||
|
// Content-Type from the server, but supply an extension-specific
|
||||||
|
// default when it is missing or generic (application/octet-stream),
|
||||||
|
// otherwise formats like webm/ogg/flac/svg won't render.
|
||||||
|
const fallbackMime = getPreviewMimeType(file.name);
|
||||||
|
const isGenericType = !blob.type || blob.type === 'application/octet-stream';
|
||||||
|
const isMislabeledVideo = kind === 'video' && Boolean(fallbackMime) && !blob.type.startsWith('video/');
|
||||||
|
let outType = isGenericType || isMislabeledVideo ? (fallbackMime ?? blob.type) : blob.type;
|
||||||
|
|
||||||
|
if (kind === 'pdf') {
|
||||||
|
// The PDF renders in a same-origin <iframe>, so verify the bytes are
|
||||||
|
// really a PDF and pin the type to application/pdf. That forces the
|
||||||
|
// browser's PDF handler and prevents a mislabeled HTML/SVG file from
|
||||||
|
// executing scripts in the app's origin.
|
||||||
|
if (!(await looksLikePdf(blob))) {
|
||||||
|
throw new Error('File is not a valid PDF');
|
||||||
|
}
|
||||||
|
outType = 'application/pdf';
|
||||||
|
}
|
||||||
|
|
||||||
|
const typed = outType && outType !== blob.type ? new Blob([blob], { type: outType }) : blob;
|
||||||
|
objectUrl = URL.createObjectURL(typed);
|
||||||
|
|
||||||
|
// The cleanup may have already run (deps changed during an await), in
|
||||||
|
// which case it revoked nothing because objectUrl was still null. Don't
|
||||||
|
// publish a URL the cleanup will never revoke — drop it ourselves.
|
||||||
|
if (controller.signal.aborted) {
|
||||||
|
URL.revokeObjectURL(objectUrl);
|
||||||
|
objectUrl = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setUrl(objectUrl);
|
||||||
|
setLoadedKey(sourceKey);
|
||||||
|
} catch (loadError: unknown) {
|
||||||
|
if (loadError instanceof Error && loadError.name === 'AbortError') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.error('Error loading preview:', loadError);
|
||||||
|
setError(labels.error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadMedia();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
controller.abort();
|
||||||
|
if (objectUrl) {
|
||||||
|
URL.revokeObjectURL(objectUrl);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [file.path, file.name, projectId, kind, sourceKey, labels.error]);
|
||||||
|
|
||||||
|
// Only expose the blob once it matches the file currently being shown, so a
|
||||||
|
// stale URL from the previous file is never rendered during a switch.
|
||||||
|
const currentUrl = url && loadedKey === sourceKey ? url : null;
|
||||||
|
|
||||||
|
// SVGs render safely inline via <img> (scripts don't execute there), but the
|
||||||
|
// open-in-new-tab link is a top-level navigation. A blob URL inherits the
|
||||||
|
// app's origin, so a user-controlled SVG with an embedded <script> would run
|
||||||
|
// as same-origin script. Withhold the new-tab action for SVGs.
|
||||||
|
const isSvg = getPreviewMimeType(file.name) === 'image/svg+xml';
|
||||||
|
const canOpenInNewTab = Boolean(currentUrl) && !isSvg;
|
||||||
|
|
||||||
|
const renderMedia = () => {
|
||||||
|
if (!currentUrl) return null;
|
||||||
|
switch (kind) {
|
||||||
|
case 'image':
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={currentUrl}
|
||||||
|
alt={file.name}
|
||||||
|
className="max-h-full max-w-full object-contain"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case 'pdf':
|
||||||
|
// Not sandboxed on purpose: the browser's built-in PDF viewer refuses to
|
||||||
|
// load inside a sandboxed frame (any `sandbox` value yields a broken
|
||||||
|
// viewer). Script execution is instead prevented upstream by validating
|
||||||
|
// the PDF magic bytes and pinning the blob's MIME type to application/pdf.
|
||||||
|
return <iframe src={currentUrl} title={file.name} className="h-full w-full border-0 bg-white" />;
|
||||||
|
case 'video':
|
||||||
|
return (
|
||||||
|
<video src={currentUrl} controls className="max-h-full max-w-full" autoPlay={false}>
|
||||||
|
{labels.error}
|
||||||
|
</video>
|
||||||
|
);
|
||||||
|
case 'audio':
|
||||||
|
return (
|
||||||
|
<div className="flex w-full max-w-xl flex-col items-center gap-4 px-6">
|
||||||
|
<p className="max-w-full truncate text-sm text-muted-foreground">{file.name}</p>
|
||||||
|
<audio src={currentUrl} controls className="w-full">
|
||||||
|
{labels.error}
|
||||||
|
</audio>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const previewBody = (
|
||||||
|
<div className="relative flex h-full w-full flex-col items-center justify-center bg-muted/30 p-2">
|
||||||
|
{loading && (
|
||||||
|
<div className="text-sm text-muted-foreground">{labels.loading}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && currentUrl && renderMedia()}
|
||||||
|
|
||||||
|
{!loading && !currentUrl && (
|
||||||
|
<div className="flex flex-col items-center gap-3 p-8 text-center text-muted-foreground">
|
||||||
|
<p className="text-sm">{error || labels.error}</p>
|
||||||
|
<p className="break-all text-xs">{file.path}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const headerActions = (
|
||||||
|
<div className="flex shrink-0 items-center gap-0.5">
|
||||||
|
{canOpenInNewTab && currentUrl && (
|
||||||
|
<a
|
||||||
|
href={currentUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center justify-center rounded-md p-1.5 text-gray-600 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white"
|
||||||
|
aria-label={labels.openInNewTab}
|
||||||
|
title={labels.openInNewTab}
|
||||||
|
>
|
||||||
|
<svg aria-hidden="true" className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{!isSidebar && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onToggleFullscreen}
|
||||||
|
className="flex items-center justify-center rounded-md p-1.5 text-gray-600 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white"
|
||||||
|
aria-label={isFullscreen ? labels.exitFullscreen : labels.fullscreen}
|
||||||
|
title={isFullscreen ? labels.exitFullscreen : labels.fullscreen}
|
||||||
|
>
|
||||||
|
{isFullscreen ? (
|
||||||
|
<svg aria-hidden="true" className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 9V4.5M9 9H4.5M9 9L3.5 3.5M9 15v4.5M9 15H4.5M9 15l-5.5 5.5M15 9h4.5M15 9V4.5M15 9l5.5-5.5M15 15h4.5M15 15v4.5m0-4.5l5.5 5.5" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg aria-hidden="true" className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="flex items-center justify-center rounded-md p-1.5 text-gray-600 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white"
|
||||||
|
aria-label={labels.close}
|
||||||
|
title={labels.close}
|
||||||
|
>
|
||||||
|
<svg aria-hidden="true" className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const header = (
|
||||||
|
<div className="flex flex-shrink-0 items-center justify-between border-b border-border px-3 py-1.5">
|
||||||
|
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||||
|
<h3 className="truncate text-sm font-medium text-gray-900 dark:text-white">{file.name}</h3>
|
||||||
|
</div>
|
||||||
|
{headerActions}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isSidebar) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full flex-col bg-background">
|
||||||
|
{header}
|
||||||
|
{previewBody}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const containerClassName = isFullscreen
|
||||||
|
? 'fixed inset-0 z-[9999] bg-background flex flex-col'
|
||||||
|
: 'fixed inset-0 z-[9999] md:bg-black/50 md:flex md:items-center md:justify-center md:p-4';
|
||||||
|
|
||||||
|
const innerClassName = isFullscreen
|
||||||
|
? 'bg-background flex flex-col w-full h-full'
|
||||||
|
: 'bg-background shadow-2xl flex flex-col w-full h-full md:rounded-lg md:shadow-2xl md:w-full md:max-w-6xl md:h-[80vh] md:max-h-[80vh]';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={containerClassName}>
|
||||||
|
<div className={innerClassName}>
|
||||||
|
{header}
|
||||||
|
{previewBody}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { ITerminalOptions } from '@xterm/xterm';
|
import type { ITerminalOptions } from '@xterm/xterm';
|
||||||
|
|
||||||
|
export const CODEX_DEVICE_AUTH_URL = 'https://auth.openai.com/codex/device';
|
||||||
export const SHELL_RESTART_DELAY_MS = 200;
|
export const SHELL_RESTART_DELAY_MS = 200;
|
||||||
export const TERMINAL_INIT_DELAY_MS = 100;
|
export const TERMINAL_INIT_DELAY_MS = 100;
|
||||||
export const TERMINAL_RESIZE_DELAY_MS = 50;
|
export const TERMINAL_RESIZE_DELAY_MS = 50;
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ type UseShellConnectionOptions = {
|
|||||||
autoConnect: boolean;
|
autoConnect: boolean;
|
||||||
closeSocket: () => void;
|
closeSocket: () => void;
|
||||||
clearTerminalScreen: () => void;
|
clearTerminalScreen: () => void;
|
||||||
|
setAuthUrl: (nextAuthUrl: string) => void;
|
||||||
onOutputRef?: MutableRefObject<(() => void) | null>;
|
onOutputRef?: MutableRefObject<(() => void) | null>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -48,6 +49,7 @@ export function useShellConnection({
|
|||||||
autoConnect,
|
autoConnect,
|
||||||
closeSocket,
|
closeSocket,
|
||||||
clearTerminalScreen,
|
clearTerminalScreen,
|
||||||
|
setAuthUrl,
|
||||||
onOutputRef,
|
onOutputRef,
|
||||||
}: UseShellConnectionOptions): UseShellConnectionResult {
|
}: UseShellConnectionOptions): UseShellConnectionResult {
|
||||||
const [isConnected, setIsConnected] = useState(false);
|
const [isConnected, setIsConnected] = useState(false);
|
||||||
@@ -98,8 +100,14 @@ export function useShellConnection({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (message.type === 'auth_url' || message.type === 'url_open') {
|
||||||
|
const nextAuthUrl = typeof message.url === 'string' ? message.url : '';
|
||||||
|
if (nextAuthUrl) {
|
||||||
|
setAuthUrl(nextAuthUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[handleProcessCompletion, onOutputRef, terminalRef],
|
[handleProcessCompletion, onOutputRef, setAuthUrl, terminalRef],
|
||||||
);
|
);
|
||||||
|
|
||||||
const connectWebSocket = useCallback(
|
const connectWebSocket = useCallback(
|
||||||
@@ -125,6 +133,7 @@ export function useShellConnection({
|
|||||||
setIsConnected(true);
|
setIsConnected(true);
|
||||||
setIsConnecting(false);
|
setIsConnecting(false);
|
||||||
connectingRef.current = false;
|
connectingRef.current = false;
|
||||||
|
setAuthUrl('');
|
||||||
|
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
const currentTerminal = terminalRef.current;
|
const currentTerminal = terminalRef.current;
|
||||||
@@ -187,6 +196,7 @@ export function useShellConnection({
|
|||||||
isPlainShellRef,
|
isPlainShellRef,
|
||||||
selectedProjectRef,
|
selectedProjectRef,
|
||||||
selectedSessionRef,
|
selectedSessionRef,
|
||||||
|
setAuthUrl,
|
||||||
terminalRef,
|
terminalRef,
|
||||||
wsRef,
|
wsRef,
|
||||||
],
|
],
|
||||||
@@ -215,7 +225,8 @@ export function useShellConnection({
|
|||||||
setIsConnecting(false);
|
setIsConnecting(false);
|
||||||
connectingRef.current = false;
|
connectingRef.current = false;
|
||||||
forceRestartOnInitRef.current = false;
|
forceRestartOnInitRef.current = false;
|
||||||
}, [clearTerminalScreen, closeSocket]);
|
setAuthUrl('');
|
||||||
|
}, [clearTerminalScreen, closeSocket, setAuthUrl]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { useCallback, useEffect, useRef } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import type { FitAddon } from '@xterm/addon-fit';
|
import type { FitAddon } from '@xterm/addon-fit';
|
||||||
import type { Terminal } from '@xterm/xterm';
|
import type { Terminal } from '@xterm/xterm';
|
||||||
|
|
||||||
import type { UseShellRuntimeOptions, UseShellRuntimeResult } from '../types/types';
|
import type { UseShellRuntimeOptions, UseShellRuntimeResult } from '../types/types';
|
||||||
|
import { copyTextToClipboard } from '../../../utils/clipboard';
|
||||||
import { useShellConnection } from './useShellConnection';
|
import { useShellConnection } from './useShellConnection';
|
||||||
import { useShellTerminal } from './useShellTerminal';
|
import { useShellTerminal } from './useShellTerminal';
|
||||||
|
|
||||||
@@ -23,11 +22,15 @@ export function useShellRuntime({
|
|||||||
const fitAddonRef = useRef<FitAddon | null>(null);
|
const fitAddonRef = useRef<FitAddon | null>(null);
|
||||||
const wsRef = useRef<WebSocket | null>(null);
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
|
|
||||||
|
const [authUrl, setAuthUrl] = useState('');
|
||||||
|
const [authUrlVersion, setAuthUrlVersion] = useState(0);
|
||||||
|
|
||||||
const selectedProjectRef = useRef(selectedProject);
|
const selectedProjectRef = useRef(selectedProject);
|
||||||
const selectedSessionRef = useRef(selectedSession);
|
const selectedSessionRef = useRef(selectedSession);
|
||||||
const initialCommandRef = useRef(initialCommand);
|
const initialCommandRef = useRef(initialCommand);
|
||||||
const isPlainShellRef = useRef(isPlainShell);
|
const isPlainShellRef = useRef(isPlainShell);
|
||||||
const onProcessCompleteRef = useRef(onProcessComplete);
|
const onProcessCompleteRef = useRef(onProcessComplete);
|
||||||
|
const authUrlRef = useRef('');
|
||||||
const lastSessionIdRef = useRef<string | null>(selectedSession?.id ?? null);
|
const lastSessionIdRef = useRef<string | null>(selectedSession?.id ?? null);
|
||||||
|
|
||||||
// Keep mutable values in refs so websocket handlers always read current data.
|
// Keep mutable values in refs so websocket handlers always read current data.
|
||||||
@@ -39,6 +42,12 @@ export function useShellRuntime({
|
|||||||
onProcessCompleteRef.current = onProcessComplete;
|
onProcessCompleteRef.current = onProcessComplete;
|
||||||
}, [selectedProject, selectedSession, initialCommand, isPlainShell, onProcessComplete]);
|
}, [selectedProject, selectedSession, initialCommand, isPlainShell, onProcessComplete]);
|
||||||
|
|
||||||
|
const setCurrentAuthUrl = useCallback((nextAuthUrl: string) => {
|
||||||
|
authUrlRef.current = nextAuthUrl;
|
||||||
|
setAuthUrl(nextAuthUrl);
|
||||||
|
setAuthUrlVersion((previous) => previous + 1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const closeSocket = useCallback(() => {
|
const closeSocket = useCallback(() => {
|
||||||
const activeSocket = wsRef.current;
|
const activeSocket = wsRef.current;
|
||||||
if (!activeSocket) {
|
if (!activeSocket) {
|
||||||
@@ -55,6 +64,32 @@ export function useShellRuntime({
|
|||||||
wsRef.current = null;
|
wsRef.current = null;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const openAuthUrlInBrowser = useCallback((url = authUrlRef.current) => {
|
||||||
|
if (!url) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const popup = window.open(url, '_blank');
|
||||||
|
if (popup) {
|
||||||
|
try {
|
||||||
|
popup.opener = null;
|
||||||
|
} catch {
|
||||||
|
// Ignore cross-origin restrictions when trying to null opener.
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const copyAuthUrlToClipboard = useCallback(async (url = authUrlRef.current) => {
|
||||||
|
if (!url) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return copyTextToClipboard(url);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const { isInitialized, clearTerminalScreen, disposeTerminal } = useShellTerminal({
|
const { isInitialized, clearTerminalScreen, disposeTerminal } = useShellTerminal({
|
||||||
terminalContainerRef,
|
terminalContainerRef,
|
||||||
terminalRef,
|
terminalRef,
|
||||||
@@ -63,6 +98,10 @@ export function useShellRuntime({
|
|||||||
selectedProject,
|
selectedProject,
|
||||||
minimal,
|
minimal,
|
||||||
isRestarting,
|
isRestarting,
|
||||||
|
initialCommandRef,
|
||||||
|
isPlainShellRef,
|
||||||
|
authUrlRef,
|
||||||
|
copyAuthUrlToClipboard,
|
||||||
closeSocket,
|
closeSocket,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -79,6 +118,7 @@ export function useShellRuntime({
|
|||||||
autoConnect,
|
autoConnect,
|
||||||
closeSocket,
|
closeSocket,
|
||||||
clearTerminalScreen,
|
clearTerminalScreen,
|
||||||
|
setAuthUrl: setCurrentAuthUrl,
|
||||||
onOutputRef,
|
onOutputRef,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -116,7 +156,11 @@ export function useShellRuntime({
|
|||||||
isConnected,
|
isConnected,
|
||||||
isInitialized,
|
isInitialized,
|
||||||
isConnecting,
|
isConnecting,
|
||||||
|
authUrl,
|
||||||
|
authUrlVersion,
|
||||||
connectToShell,
|
connectToShell,
|
||||||
disconnectFromShell,
|
disconnectFromShell,
|
||||||
|
openAuthUrlInBrowser,
|
||||||
|
copyAuthUrlToClipboard,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,18 +4,15 @@ import { FitAddon } from '@xterm/addon-fit';
|
|||||||
import { WebLinksAddon } from '@xterm/addon-web-links';
|
import { WebLinksAddon } from '@xterm/addon-web-links';
|
||||||
import { WebglAddon } from '@xterm/addon-webgl';
|
import { WebglAddon } from '@xterm/addon-webgl';
|
||||||
import { Terminal } from '@xterm/xterm';
|
import { Terminal } from '@xterm/xterm';
|
||||||
|
|
||||||
import type { Project } from '../../../types/app';
|
import type { Project } from '../../../types/app';
|
||||||
import { copyTextToClipboard } from '../../../utils/clipboard';
|
|
||||||
import {
|
import {
|
||||||
|
CODEX_DEVICE_AUTH_URL,
|
||||||
TERMINAL_INIT_DELAY_MS,
|
TERMINAL_INIT_DELAY_MS,
|
||||||
TERMINAL_OPTIONS,
|
TERMINAL_OPTIONS,
|
||||||
TERMINAL_RESIZE_DELAY_MS,
|
TERMINAL_RESIZE_DELAY_MS,
|
||||||
} from '../constants/constants';
|
} from '../constants/constants';
|
||||||
import {
|
import { copyTextToClipboard } from '../../../utils/clipboard';
|
||||||
installMobileTerminalSelection,
|
import { isCodexLoginCommand } from '../utils/auth';
|
||||||
type MobileTerminalSelectionManager,
|
|
||||||
} from '../utils/mobileTerminalSelection';
|
|
||||||
import { sendSocketMessage } from '../utils/socket';
|
import { sendSocketMessage } from '../utils/socket';
|
||||||
import { ensureXtermFocusStyles } from '../utils/terminalStyles';
|
import { ensureXtermFocusStyles } from '../utils/terminalStyles';
|
||||||
|
|
||||||
@@ -27,6 +24,10 @@ type UseShellTerminalOptions = {
|
|||||||
selectedProject: Project | null | undefined;
|
selectedProject: Project | null | undefined;
|
||||||
minimal: boolean;
|
minimal: boolean;
|
||||||
isRestarting: boolean;
|
isRestarting: boolean;
|
||||||
|
initialCommandRef: MutableRefObject<string | null | undefined>;
|
||||||
|
isPlainShellRef: MutableRefObject<boolean>;
|
||||||
|
authUrlRef: MutableRefObject<string>;
|
||||||
|
copyAuthUrlToClipboard: (url?: string) => Promise<boolean>;
|
||||||
closeSocket: () => void;
|
closeSocket: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -44,11 +45,14 @@ export function useShellTerminal({
|
|||||||
selectedProject,
|
selectedProject,
|
||||||
minimal,
|
minimal,
|
||||||
isRestarting,
|
isRestarting,
|
||||||
|
initialCommandRef,
|
||||||
|
isPlainShellRef,
|
||||||
|
authUrlRef,
|
||||||
|
copyAuthUrlToClipboard,
|
||||||
closeSocket,
|
closeSocket,
|
||||||
}: UseShellTerminalOptions): UseShellTerminalResult {
|
}: UseShellTerminalOptions): UseShellTerminalResult {
|
||||||
const [isInitialized, setIsInitialized] = useState(false);
|
const [isInitialized, setIsInitialized] = useState(false);
|
||||||
const resizeTimeoutRef = useRef<number | null>(null);
|
const resizeTimeoutRef = useRef<number | null>(null);
|
||||||
const mobileSelectionRef = useRef<MobileTerminalSelectionManager | null>(null);
|
|
||||||
const selectedProjectKey = selectedProject?.fullPath || selectedProject?.path || '';
|
const selectedProjectKey = selectedProject?.fullPath || selectedProject?.path || '';
|
||||||
const hasSelectedProject = Boolean(selectedProject);
|
const hasSelectedProject = Boolean(selectedProject);
|
||||||
|
|
||||||
@@ -66,11 +70,6 @@ export function useShellTerminal({
|
|||||||
}, [terminalRef]);
|
}, [terminalRef]);
|
||||||
|
|
||||||
const disposeTerminal = useCallback(() => {
|
const disposeTerminal = useCallback(() => {
|
||||||
if (mobileSelectionRef.current) {
|
|
||||||
mobileSelectionRef.current.dispose();
|
|
||||||
mobileSelectionRef.current = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (terminalRef.current) {
|
if (terminalRef.current) {
|
||||||
terminalRef.current.dispose();
|
terminalRef.current.dispose();
|
||||||
terminalRef.current = null;
|
terminalRef.current = null;
|
||||||
@@ -81,8 +80,7 @@ export function useShellTerminal({
|
|||||||
}, [fitAddonRef, terminalRef]);
|
}, [fitAddonRef, terminalRef]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const terminalContainer = terminalContainerRef.current;
|
if (!terminalContainerRef.current || !hasSelectedProject || isRestarting || terminalRef.current) {
|
||||||
if (!terminalContainer || !hasSelectedProject || isRestarting || terminalRef.current) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,28 +102,7 @@ export function useShellTerminal({
|
|||||||
console.warn('[Shell] WebGL renderer unavailable, using Canvas fallback');
|
console.warn('[Shell] WebGL renderer unavailable, using Canvas fallback');
|
||||||
}
|
}
|
||||||
|
|
||||||
nextTerminal.open(terminalContainer);
|
nextTerminal.open(terminalContainerRef.current);
|
||||||
mobileSelectionRef.current = installMobileTerminalSelection(
|
|
||||||
nextTerminal,
|
|
||||||
terminalContainer,
|
|
||||||
{
|
|
||||||
onFontSizeChange: (fontSize) => {
|
|
||||||
nextTerminal.options.fontSize = fontSize;
|
|
||||||
|
|
||||||
const currentFitAddon = fitAddonRef.current;
|
|
||||||
if (currentFitAddon) {
|
|
||||||
currentFitAddon.fit();
|
|
||||||
sendSocketMessage(wsRef.current, {
|
|
||||||
type: 'resize',
|
|
||||||
cols: nextTerminal.cols,
|
|
||||||
rows: nextTerminal.rows,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
nextTerminal.refresh(0, nextTerminal.rows - 1);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const copyTerminalSelection = async () => {
|
const copyTerminalSelection = async () => {
|
||||||
const selection = nextTerminal.getSelection();
|
const selection = nextTerminal.getSelection();
|
||||||
@@ -156,9 +133,29 @@ export function useShellTerminal({
|
|||||||
void copyTextToClipboard(selection);
|
void copyTextToClipboard(selection);
|
||||||
};
|
};
|
||||||
|
|
||||||
terminalContainer.addEventListener('copy', handleTerminalCopy);
|
terminalContainerRef.current.addEventListener('copy', handleTerminalCopy);
|
||||||
|
|
||||||
nextTerminal.attachCustomKeyEventHandler((event) => {
|
nextTerminal.attachCustomKeyEventHandler((event) => {
|
||||||
|
const activeAuthUrl = isCodexLoginCommand(initialCommandRef.current)
|
||||||
|
? CODEX_DEVICE_AUTH_URL
|
||||||
|
: authUrlRef.current;
|
||||||
|
|
||||||
|
if (
|
||||||
|
event.type === 'keydown' &&
|
||||||
|
minimal &&
|
||||||
|
isPlainShellRef.current &&
|
||||||
|
activeAuthUrl &&
|
||||||
|
!event.ctrlKey &&
|
||||||
|
!event.metaKey &&
|
||||||
|
!event.altKey &&
|
||||||
|
event.key?.toLowerCase() === 'c'
|
||||||
|
) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
void copyAuthUrlToClipboard(activeAuthUrl);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
event.type === 'keydown' &&
|
event.type === 'keydown' &&
|
||||||
(event.ctrlKey || event.metaKey) &&
|
(event.ctrlKey || event.metaKey) &&
|
||||||
@@ -243,10 +240,10 @@ export function useShellTerminal({
|
|||||||
}, TERMINAL_RESIZE_DELAY_MS);
|
}, TERMINAL_RESIZE_DELAY_MS);
|
||||||
});
|
});
|
||||||
|
|
||||||
resizeObserver.observe(terminalContainer);
|
resizeObserver.observe(terminalContainerRef.current);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
terminalContainer.removeEventListener('copy', handleTerminalCopy);
|
terminalContainerRef.current?.removeEventListener('copy', handleTerminalCopy);
|
||||||
resizeObserver.disconnect();
|
resizeObserver.disconnect();
|
||||||
if (resizeTimeoutRef.current !== null) {
|
if (resizeTimeoutRef.current !== null) {
|
||||||
window.clearTimeout(resizeTimeoutRef.current);
|
window.clearTimeout(resizeTimeoutRef.current);
|
||||||
@@ -257,12 +254,16 @@ export function useShellTerminal({
|
|||||||
disposeTerminal();
|
disposeTerminal();
|
||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
|
authUrlRef,
|
||||||
closeSocket,
|
closeSocket,
|
||||||
|
copyAuthUrlToClipboard,
|
||||||
disposeTerminal,
|
disposeTerminal,
|
||||||
fitAddonRef,
|
fitAddonRef,
|
||||||
|
initialCommandRef,
|
||||||
|
isPlainShellRef,
|
||||||
isRestarting,
|
isRestarting,
|
||||||
hasSelectedProject,
|
|
||||||
minimal,
|
minimal,
|
||||||
|
hasSelectedProject,
|
||||||
selectedProjectKey,
|
selectedProjectKey,
|
||||||
terminalContainerRef,
|
terminalContainerRef,
|
||||||
terminalRef,
|
terminalRef,
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import type { Terminal } from '@xterm/xterm';
|
|||||||
|
|
||||||
import type { Project, ProjectSession } from '../../../types/app';
|
import type { Project, ProjectSession } from '../../../types/app';
|
||||||
|
|
||||||
|
export type AuthCopyStatus = 'idle' | 'copied' | 'failed';
|
||||||
|
|
||||||
export type ShellInitMessage = {
|
export type ShellInitMessage = {
|
||||||
type: 'init';
|
type: 'init';
|
||||||
projectPath: string;
|
projectPath: string;
|
||||||
@@ -52,6 +54,7 @@ export type ShellSharedRefs = {
|
|||||||
wsRef: MutableRefObject<WebSocket | null>;
|
wsRef: MutableRefObject<WebSocket | null>;
|
||||||
terminalRef: MutableRefObject<Terminal | null>;
|
terminalRef: MutableRefObject<Terminal | null>;
|
||||||
fitAddonRef: MutableRefObject<FitAddon | null>;
|
fitAddonRef: MutableRefObject<FitAddon | null>;
|
||||||
|
authUrlRef: MutableRefObject<string>;
|
||||||
selectedProjectRef: MutableRefObject<Project | null | undefined>;
|
selectedProjectRef: MutableRefObject<Project | null | undefined>;
|
||||||
selectedSessionRef: MutableRefObject<ProjectSession | null | undefined>;
|
selectedSessionRef: MutableRefObject<ProjectSession | null | undefined>;
|
||||||
initialCommandRef: MutableRefObject<string | null | undefined>;
|
initialCommandRef: MutableRefObject<string | null | undefined>;
|
||||||
@@ -66,6 +69,10 @@ export type UseShellRuntimeResult = {
|
|||||||
isConnected: boolean;
|
isConnected: boolean;
|
||||||
isInitialized: boolean;
|
isInitialized: boolean;
|
||||||
isConnecting: boolean;
|
isConnecting: boolean;
|
||||||
|
authUrl: string;
|
||||||
|
authUrlVersion: number;
|
||||||
connectToShell: (options?: { forceRestart?: boolean }) => void;
|
connectToShell: (options?: { forceRestart?: boolean }) => void;
|
||||||
disconnectFromShell: (options?: { suppressAutoConnect?: boolean }) => void;
|
disconnectFromShell: (options?: { suppressAutoConnect?: boolean }) => void;
|
||||||
|
openAuthUrlInBrowser: (url?: string) => boolean;
|
||||||
|
copyAuthUrlToClipboard: (url?: string) => Promise<boolean>;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,17 @@
|
|||||||
import type { ProjectSession } from '../../../types/app';
|
import type { ProjectSession } from '../../../types/app';
|
||||||
|
import { CODEX_DEVICE_AUTH_URL } from '../constants/constants';
|
||||||
|
|
||||||
|
export function isCodexLoginCommand(command: string | null | undefined): boolean {
|
||||||
|
return typeof command === 'string' && /\bcodex\s+login\b/i.test(command);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveAuthUrlForDisplay(command: string | null | undefined, authUrl: string): string {
|
||||||
|
if (isCodexLoginCommand(command)) {
|
||||||
|
return CODEX_DEVICE_AUTH_URL;
|
||||||
|
}
|
||||||
|
|
||||||
|
return authUrl;
|
||||||
|
}
|
||||||
|
|
||||||
export function getSessionDisplayName(session: ProjectSession | null | undefined): string | null {
|
export function getSessionDisplayName(session: ProjectSession | null | undefined): string | null {
|
||||||
if (!session) {
|
if (!session) {
|
||||||
@@ -8,4 +21,4 @@ export function getSessionDisplayName(session: ProjectSession | null | undefined
|
|||||||
return session.__provider === 'cursor'
|
return session.__provider === 'cursor'
|
||||||
? session.name || 'Untitled Session'
|
? session.name || 'Untitled Session'
|
||||||
: session.summary || 'New Session';
|
: session.summary || 'New Session';
|
||||||
}
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -59,8 +59,12 @@ export default function Shell({
|
|||||||
isConnected,
|
isConnected,
|
||||||
isInitialized,
|
isInitialized,
|
||||||
isConnecting,
|
isConnecting,
|
||||||
|
authUrl,
|
||||||
|
authUrlVersion,
|
||||||
connectToShell,
|
connectToShell,
|
||||||
disconnectFromShell,
|
disconnectFromShell,
|
||||||
|
openAuthUrlInBrowser,
|
||||||
|
copyAuthUrlToClipboard,
|
||||||
} = useShellRuntime({
|
} = useShellRuntime({
|
||||||
selectedProject,
|
selectedProject,
|
||||||
selectedSession,
|
selectedSession,
|
||||||
@@ -239,7 +243,15 @@ export default function Shell({
|
|||||||
if (minimal) {
|
if (minimal) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ShellMinimalView terminalContainerRef={terminalContainerRef} />
|
<ShellMinimalView
|
||||||
|
terminalContainerRef={terminalContainerRef}
|
||||||
|
authUrl={authUrl}
|
||||||
|
authUrlVersion={authUrlVersion}
|
||||||
|
initialCommand={initialCommand}
|
||||||
|
isConnected={isConnected}
|
||||||
|
openAuthUrlInBrowser={openAuthUrlInBrowser}
|
||||||
|
copyAuthUrlToClipboard={copyAuthUrlToClipboard}
|
||||||
|
/>
|
||||||
<TerminalShortcutsPanel
|
<TerminalShortcutsPanel
|
||||||
wsRef={wsRef}
|
wsRef={wsRef}
|
||||||
terminalRef={terminalRef}
|
terminalRef={terminalRef}
|
||||||
|
|||||||
@@ -1,12 +1,45 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import type { RefObject } from 'react';
|
import type { RefObject } from 'react';
|
||||||
|
import type { AuthCopyStatus } from '../../types/types';
|
||||||
|
import { resolveAuthUrlForDisplay } from '../../utils/auth';
|
||||||
|
|
||||||
type ShellMinimalViewProps = {
|
type ShellMinimalViewProps = {
|
||||||
terminalContainerRef: RefObject<HTMLDivElement>;
|
terminalContainerRef: RefObject<HTMLDivElement>;
|
||||||
|
authUrl: string;
|
||||||
|
authUrlVersion: number;
|
||||||
|
initialCommand: string | null | undefined;
|
||||||
|
isConnected: boolean;
|
||||||
|
openAuthUrlInBrowser: (url: string) => boolean;
|
||||||
|
copyAuthUrlToClipboard: (url: string) => Promise<boolean>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ShellMinimalView({
|
export default function ShellMinimalView({
|
||||||
terminalContainerRef,
|
terminalContainerRef,
|
||||||
|
authUrl,
|
||||||
|
authUrlVersion,
|
||||||
|
initialCommand,
|
||||||
|
isConnected,
|
||||||
|
openAuthUrlInBrowser,
|
||||||
|
copyAuthUrlToClipboard,
|
||||||
}: ShellMinimalViewProps) {
|
}: ShellMinimalViewProps) {
|
||||||
|
const [authUrlCopyStatus, setAuthUrlCopyStatus] = useState<AuthCopyStatus>('idle');
|
||||||
|
const [isAuthPanelHidden, setIsAuthPanelHidden] = useState(false);
|
||||||
|
|
||||||
|
const displayAuthUrl = useMemo(
|
||||||
|
() => resolveAuthUrlForDisplay(initialCommand, authUrl),
|
||||||
|
[authUrl, initialCommand],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Keep auth panel UI state local to minimal mode and reset it when connection/url changes.
|
||||||
|
useEffect(() => {
|
||||||
|
setAuthUrlCopyStatus('idle');
|
||||||
|
setIsAuthPanelHidden(false);
|
||||||
|
}, [authUrlVersion, displayAuthUrl, isConnected]);
|
||||||
|
|
||||||
|
const hasAuthUrl = Boolean(displayAuthUrl);
|
||||||
|
const showMobileAuthPanel = hasAuthUrl && !isAuthPanelHidden;
|
||||||
|
const showMobileAuthPanelToggle = hasAuthUrl && isAuthPanelHidden;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative h-full w-full bg-gray-900">
|
<div className="relative h-full w-full bg-gray-900">
|
||||||
<div
|
<div
|
||||||
@@ -14,6 +47,67 @@ export default function ShellMinimalView({
|
|||||||
className="h-full w-full focus:outline-none"
|
className="h-full w-full focus:outline-none"
|
||||||
style={{ outline: 'none' }}
|
style={{ outline: 'none' }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{showMobileAuthPanel && (
|
||||||
|
<div className="absolute inset-x-0 bottom-14 z-20 border-t border-gray-700/80 bg-gray-900/95 p-3 backdrop-blur-sm md:hidden">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<p className="text-xs text-gray-300">Open or copy the login URL:</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsAuthPanelHidden(true)}
|
||||||
|
className="rounded bg-gray-700 px-2 py-1 text-[10px] font-medium uppercase tracking-wide text-gray-100 hover:bg-gray-600"
|
||||||
|
>
|
||||||
|
Hide
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={displayAuthUrl}
|
||||||
|
readOnly
|
||||||
|
onClick={(event) => event.currentTarget.select()}
|
||||||
|
className="w-full rounded border border-gray-600 bg-gray-800 px-2 py-1 text-xs text-gray-100 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||||
|
aria-label="Authentication URL"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
openAuthUrlInBrowser(displayAuthUrl);
|
||||||
|
}}
|
||||||
|
className="flex-1 rounded bg-blue-600 px-3 py-2 text-xs font-medium text-white hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
Open URL
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={async () => {
|
||||||
|
const copied = await copyAuthUrlToClipboard(displayAuthUrl);
|
||||||
|
setAuthUrlCopyStatus(copied ? 'copied' : 'failed');
|
||||||
|
}}
|
||||||
|
className="flex-1 rounded bg-gray-700 px-3 py-2 text-xs font-medium text-white hover:bg-gray-600"
|
||||||
|
>
|
||||||
|
{authUrlCopyStatus === 'copied' ? 'Copied' : 'Copy URL'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showMobileAuthPanelToggle && (
|
||||||
|
<div className="absolute bottom-14 right-3 z-20 md:hidden">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsAuthPanelHidden(false)}
|
||||||
|
className="rounded bg-gray-800/95 px-3 py-2 text-xs font-medium text-gray-100 shadow-lg backdrop-blur-sm hover:bg-gray-700"
|
||||||
|
>
|
||||||
|
Show login URL
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ const MOBILE_KEYS: Shortcut[] = [
|
|||||||
{ type: 'arrow', id: 'arrow-down', sequence: '\x1b[B', icon: 'down' },
|
{ type: 'arrow', id: 'arrow-down', sequence: '\x1b[B', icon: 'down' },
|
||||||
{ type: 'arrow', id: 'arrow-left', sequence: '\x1b[D', icon: 'left' },
|
{ type: 'arrow', id: 'arrow-left', sequence: '\x1b[D', icon: 'left' },
|
||||||
{ type: 'arrow', id: 'arrow-right', sequence: '\x1b[C', icon: 'right' },
|
{ type: 'arrow', id: 'arrow-right', sequence: '\x1b[C', icon: 'right' },
|
||||||
{ type: 'key', id: 'ctrl-c', label: 'Ctrl+C', sequence: '\x03' },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const ARROW_ICONS = {
|
const ARROW_ICONS = {
|
||||||
|
|||||||
@@ -32,5 +32,10 @@
|
|||||||
"binaryFile": {
|
"binaryFile": {
|
||||||
"title": "Binary File",
|
"title": "Binary File",
|
||||||
"message": "The file \"{{fileName}}\" cannot be displayed in the text editor because it is a binary file."
|
"message": "The file \"{{fileName}}\" cannot be displayed in the text editor because it is a binary file."
|
||||||
|
},
|
||||||
|
"filePreview": {
|
||||||
|
"loading": "Loading preview...",
|
||||||
|
"error": "Unable to display this file.",
|
||||||
|
"openInNewTab": "Open in new tab"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -137,12 +137,6 @@
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
/* The app shell is a fixed inset-0 container (see AppContent), so the
|
|
||||||
document itself never needs to scroll. Clipping it removes the phantom
|
|
||||||
full-height page scrollbar and disables the browser pull-to-refresh
|
|
||||||
gesture that reloads the page when scrolling up on mobile. */
|
|
||||||
overflow: hidden;
|
|
||||||
overscroll-behavior-y: contain;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Root element with safe area padding for PWA */
|
/* Root element with safe area padding for PWA */
|
||||||
|
|||||||
Reference in New Issue
Block a user