sync: update from private repo (ea88916)
CI / build-and-test (push) Has been cancelled

This commit is contained in:
oss-sync
2026-06-08 03:58:10 +00:00
parent 0f75bdfbab
commit 38bd874366
14 changed files with 1500 additions and 303 deletions
+211 -5
View File
@@ -1,14 +1,53 @@
import { useRef } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useMemo, useRef, useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useConsoleSession } from '../../../hooks/useConsoleSession';
import type { ConsoleStatus } from '../../../lib/ssh-console-types';
import type { SshConnection } from '../../../lib/ssh-types';
import { TerminalView, type TerminalViewHandle } from './console/TerminalView';
import { ConsoleHeader } from './console/ConsoleHeader';
import { MobileKeyboardBar } from './console/MobileKeyboardBar';
import { ScrollToBottomButton } from './console/ScrollToBottomButton';
import { useViewportNarrow } from '../../layout/TopBar';
async function fetchConnections(): Promise<{ list: SshConnection[]; sshDisabled: boolean }> {
const res = await fetch('/api/ssh/connections', { credentials: 'include' });
if (res.status === 404) return { list: [], sshDisabled: true };
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = (await res.json()) as { connections: SshConnection[] };
return { list: data.connections ?? [], sshDisabled: false };
}
/** Map a structured `{ error }` code from the session endpoint to a user-facing message. */
function describeSessionError(code: string): { msg: string; hardStop: boolean } {
switch (code) {
case 'host_key_not_verified':
return {
msg: '接続の host key を検証してください(Settings → SSH Connections → Test',
hardStop: false,
};
case 'no_grant':
return {
msg: 'この接続への権限がありません(admin に grant を依頼してください)',
hardStop: false,
};
case 'host_key_mismatch':
return {
msg: 'host key 不一致(MITM の可能性)。admin に連絡してください',
hardStop: true,
};
case 'connection_disabled':
return { msg: 'この接続は無効化されています', hardStop: false };
case 'abuse_locked':
return { msg: 'この接続は一時的にロックされています(abuse 検知)', hardStop: false };
case 'connection_not_found':
return { msg: '接続が見つかりません', hardStop: false };
default:
return { msg: `セッションを開始できませんでした: ${code}`, hardStop: false };
}
}
export function ConsoleTab({ taskId }: { taskId: number }) {
const qc = useQueryClient();
const { data: status } = useQuery<ConsoleStatus>({
queryKey: ['console-status', taskId],
queryFn: async () => {
@@ -24,14 +63,181 @@ export function ConsoleTab({ taskId }: { taskId: number }) {
// and scroll-to-bottom FAB become useful.
const compactMode = useViewportNarrow(768);
const showPicker = !status?.active;
return (
<div className="flex flex-col flex-1 min-h-0">
<ConsoleHeader state={session.state} status={status ?? null} />
<div className="flex-1 min-h-0 relative">
<TerminalView ref={terminalRef} session={session} />
{compactMode && <ScrollToBottomButton terminalRef={terminalRef} />}
{showPicker ? (
<ConnectionPicker
taskId={taskId}
onStarted={() => {
// The session now exists server-side; refresh status and force
// an immediate WS attach instead of waiting for the 5s poll.
qc.invalidateQueries({ queryKey: ['console-status', taskId] });
session.reconnectNow();
}}
/>
) : (
<>
<TerminalView ref={terminalRef} session={session} />
{compactMode && <ScrollToBottomButton terminalRef={terminalRef} />}
</>
)}
</div>
{compactMode && !showPicker && <MobileKeyboardBar session={session} />}
</div>
);
}
function ConnectionPicker({
taskId,
onStarted,
}: {
taskId: number;
onStarted: () => void;
}) {
const { data, isLoading, error } = useQuery({
queryKey: ['ssh', 'connections'],
queryFn: fetchConnections,
staleTime: 15_000,
});
const connections = useMemo(
() => (data?.list ?? []).filter((c) => c.enabled && !c.disabledByAdmin),
[data],
);
const [selectedId, setSelectedId] = useState<string>('');
const [submitting, setSubmitting] = useState(false);
const [errMsg, setErrMsg] = useState<{ msg: string; hardStop: boolean } | null>(null);
// Set when the server reports an existing session on a different connection;
// lets the user re-POST with force_replace to take over the session.
const [replaceCandidate, setReplaceCandidate] = useState<string | null>(null);
// Default the select to the first connection once loaded.
const effectiveId = selectedId || connections[0]?.id || '';
async function start(forceReplace: boolean) {
if (!effectiveId) return;
setSubmitting(true);
setErrMsg(null);
setReplaceCandidate(null);
try {
const res = await fetch(`/api/local/tasks/${taskId}/console/session`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
connection_id: effectiveId,
...(forceReplace ? { force_replace: true } : {}),
}),
});
if (res.ok) {
onStarted();
return;
}
let code = `HTTP ${res.status}`;
try {
const j = (await res.json()) as { error?: string };
if (j?.error) code = j.error;
} catch {
// non-JSON body; keep HTTP status as the code
}
if (code === 'connection_change_requires_force') {
setReplaceCandidate(effectiveId);
setErrMsg({
msg: '別の接続のセッションが既に存在します。置き換えて開始できます。',
hardStop: false,
});
return;
}
setErrMsg(describeSessionError(code));
} catch (e) {
setErrMsg({ msg: e instanceof Error ? e.message : String(e), hardStop: false });
} finally {
setSubmitting(false);
}
}
if (data?.sshDisabled) {
return (
<div className="absolute inset-0 flex items-center justify-center p-6 bg-[#0b1020]">
<div className="max-w-md text-xs text-slate-300 bg-surface/10 border border-hairline rounded-md p-4 leading-relaxed">
SSH <code className="font-mono">config.yaml</code> {' '}
<code className="font-mono">ssh.enabled: true</code>
</div>
</div>
);
}
return (
<div className="absolute inset-0 flex items-center justify-center p-6 bg-[#0b1020]">
<div className="w-full max-w-md space-y-3">
<div>
<h3 className="text-sm font-semibold text-slate-100">SSH </h3>
<p className="text-2xs text-slate-400 mt-0.5">
AI
</p>
</div>
{isLoading && <div className="text-xs text-slate-400">Loading</div>}
{error && <div className="text-xs text-red-400">: {String(error)}</div>}
{!isLoading && connections.length === 0 ? (
<div className="text-xs text-slate-300 bg-surface/10 border border-hairline rounded-md p-3 leading-relaxed">
SSH Settings SSH Connections /grant
</div>
) : (
<>
<select
value={effectiveId}
onChange={(e) => { setSelectedId(e.target.value); setErrMsg(null); setReplaceCandidate(null); }}
disabled={submitting}
className="w-full text-xs px-2 py-1.5 bg-surface border border-hairline rounded text-slate-100 disabled:opacity-50"
>
{connections.map((c) => (
<option key={c.id} value={c.id}>
{c.label} {c.username}@{c.host}:{c.port}
</option>
))}
</select>
<button
type="button"
onClick={() => start(false)}
disabled={submitting || !effectiveId}
className="w-full px-3 h-8 text-xs font-semibold bg-accent text-accent-fg rounded-md hover:bg-accent-deep disabled:opacity-50"
>
{submitting ? '開始中…' : 'セッション開始'}
</button>
{replaceCandidate && (
<button
type="button"
onClick={() => start(true)}
disabled={submitting}
className="w-full px-3 h-8 text-xs font-semibold border border-amber-400/50 text-amber-300 rounded-md hover:bg-amber-500/15 disabled:opacity-50"
>
</button>
)}
</>
)}
{errMsg && (
<div
className={
errMsg.hardStop
? 'text-xs text-red-200 bg-red-500/20 border border-red-500/50 rounded-md p-3 leading-relaxed'
: 'text-xs text-amber-200 bg-amber-500/10 border border-amber-500/30 rounded-md p-3 leading-relaxed'
}
>
{errMsg.msg}
</div>
)}
</div>
{compactMode && <MobileKeyboardBar session={session} />}
</div>
);
}
+250
View File
@@ -0,0 +1,250 @@
import { useEffect, useRef, useState, type ReactNode } from 'react';
import { type Axis, lockAxis, canScrollFurther, resist, shouldCommit, neighborIndex } from '../../lib/tab-swipe';
interface SwipeableTabsProps<T extends string> {
/** Visible tab ids, in order. */
tabs: T[];
activeTab: T;
onTabChange: (tab: T) => void;
/** Swiping right past the threshold while on the FIRST tab (e.g. close). */
onSwipeBackFromFirst?: () => void;
/**
* Render a tab's content. `preview` is true for the neighbor mounted DURING a
* drag — return a lightweight placeholder for side-effecting tabs (e.g. a
* noVNC iframe or SSH WebSocket) so a partial/cancelled swipe doesn't open
* connections; the real content mounts once the swipe commits (preview=false).
*/
renderTab: (tab: T, preview: boolean) => ReactNode;
}
type Mode = 'idle' | 'pending' | 'vert' | 'scroll' | 'drag';
/**
* Horizontal swipe-between-tabs with finger-following content and a peeking
* neighbor (iOS-style). Native horizontal scrolling INSIDE the content (e.g. a
* wide <pre>) wins until it reaches its edge, so scrolling a code block no
* longer steals the tab gesture. The active layer's transform is mutated
* imperatively during the drag so the (heavy) tab panels don't re-render per
* frame; the neighbor is mounted only while a drag is in progress.
*/
export function SwipeableTabs<T extends string>({
tabs,
activeTab,
onTabChange,
onSwipeBackFromFirst,
renderTab,
}: SwipeableTabsProps<T>) {
const containerRef = useRef<HTMLDivElement>(null);
const activeRef = useRef<HTMLDivElement>(null);
const peekRef = useRef<HTMLDivElement>(null);
// The neighbor tab to mount while dragging ('left' = next on the right edge,
// 'right' = prev on the left edge). null = no drag in progress.
const [peek, setPeek] = useState<{ dir: 'left' | 'right'; tab: T } | null>(null);
const g = useRef({
startX: 0,
startY: 0,
axis: null as Axis | null,
mode: 'idle' as Mode,
scroller: null as Element | null,
dir: null as 'left' | 'right' | null,
width: 0,
dragX: 0,
rawDx: 0,
});
// Latest props for the native (re-attached) handlers.
const propsRef = useRef({ tabs, activeTab, onTabChange, onSwipeBackFromFirst });
propsRef.current = { tabs, activeTab, onTabChange, onSwipeBackFromFirst };
const baseOffset = (dir: 'left' | 'right') => (dir === 'left' ? '100%' : '-100%');
const paint = (dx: number, animate: boolean) => {
const a = activeRef.current;
const p = peekRef.current;
const t = animate ? 'transform 0.24s cubic-bezier(0.22,0.61,0.36,1)' : 'none';
if (a) {
a.style.transition = t;
a.style.transform = `translate3d(${dx}px,0,0)`;
}
if (p && g.current.dir) {
p.style.transition = t;
p.style.transform = `translate3d(calc(${baseOffset(g.current.dir)} + ${dx}px),0,0)`;
}
};
// Find the nearest horizontally-scrollable ancestor between `el` and the
// container (inclusive of el, exclusive of container).
const findScroller = (el: Element | null): Element | null => {
let node = el;
const stop = containerRef.current;
while (node && node !== stop) {
if (node instanceof HTMLElement) {
const ox = getComputedStyle(node).overflowX;
if ((ox === 'auto' || ox === 'scroll') && node.scrollWidth - node.clientWidth > 1) {
return node;
}
}
node = node.parentElement;
}
return null;
};
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const reset = () => {
g.current.mode = 'idle';
g.current.axis = null;
g.current.scroller = null;
g.current.dir = null;
g.current.dragX = 0;
};
const onStart = (e: TouchEvent) => {
if (e.touches.length !== 1) return;
g.current.mode = 'idle';
// Don't fight cursor placement, button presses, or link taps: ignore
// gestures that begin on a form control / button / anchor / editable.
const target = e.target as Element | null;
if (target?.closest('input, textarea, select, button, a, [contenteditable="true"], [data-no-swipe]')) {
return;
}
const t = e.touches[0];
g.current.startX = t.clientX;
g.current.startY = t.clientY;
g.current.axis = null;
g.current.mode = 'pending';
g.current.scroller = findScroller(e.target as Element | null);
g.current.width = el.clientWidth;
g.current.dir = null;
g.current.dragX = 0;
};
const onMove = (e: TouchEvent) => {
if (g.current.mode === 'idle' || g.current.mode === 'vert' || g.current.mode === 'scroll') return;
const t = e.touches[0];
if (!t) return;
const dx = t.clientX - g.current.startX;
const dy = t.clientY - g.current.startY;
if (g.current.axis === null) {
const a = lockAxis(dx, dy);
if (!a) return;
g.current.axis = a;
if (a === 'v') {
g.current.mode = 'vert';
return;
}
}
if (g.current.axis !== 'h') return;
// Only decide to yield to native scroll BEFORE a drag has started. Once
// dragging, reversing direction must not hand the gesture back to the
// scroller mid-drag (that would leave the panels painted off-center).
const sc = g.current.scroller;
if (g.current.mode !== 'drag' && sc && canScrollFurther(sc as HTMLElement, dx)) {
g.current.mode = 'scroll';
return;
}
e.preventDefault(); // own the horizontal gesture
const { tabs: ts, activeTab: at } = propsRef.current;
const idx = ts.indexOf(at);
const ni = neighborIndex(idx, dx, ts.length);
const hasNeighbor = ni !== null;
const dir: 'left' | 'right' = dx < 0 ? 'left' : 'right';
if (g.current.mode !== 'drag') {
g.current.mode = 'drag';
}
if (g.current.dir !== dir) {
g.current.dir = dir;
// Mount the neighbor (or null at an edge so the layer just rubber-bands).
setPeek(hasNeighbor ? { dir, tab: ts[ni] } : null);
}
g.current.rawDx = dx;
g.current.dragX = resist(dx, hasNeighbor, g.current.width);
paint(g.current.dragX, false);
};
const finish = () => {
if (g.current.mode !== 'drag') {
reset();
return;
}
const { tabs: ts, activeTab: at, onTabChange: change, onSwipeBackFromFirst: back } = propsRef.current;
const idx = ts.indexOf(at);
const dragX = g.current.dragX;
const width = g.current.width;
const ni = neighborIndex(idx, dragX, ts.length);
const dir = g.current.dir;
// First tab + rightward swipe past threshold → "back" (close), no neighbor.
// Use the RAW (un-resisted) movement: dragX is rubber-banded to ~12% width
// at an edge, so it could never reach the threshold otherwise.
if (ni === null && dir === 'right' && idx === 0 && back && Math.abs(g.current.rawDx) >= Math.max(60, width * 0.22)) {
paint(0, true);
window.setTimeout(() => { reset(); setPeek(null); }, 240);
back();
return;
}
if (ni !== null && shouldCommit(dragX, width, true)) {
const target = ts[ni];
// Slide fully, then commit the tab change and snap back to center.
paint(dir === 'left' ? -width : width, true);
window.setTimeout(() => {
change(target);
setPeek(null);
g.current.dir = null;
requestAnimationFrame(() => paint(0, false));
reset();
}, 240);
} else {
paint(0, true);
window.setTimeout(() => { setPeek(null); reset(); }, 240);
}
};
// touchcancel = the OS/browser aborted the gesture (notification, multi-touch,
// interruption) — NOT a user commit. Snap back without changing tabs.
const cancel = () => {
if (g.current.mode === 'drag') {
paint(0, true);
window.setTimeout(() => { setPeek(null); reset(); }, 240);
} else {
reset();
}
};
el.addEventListener('touchstart', onStart, { passive: true });
el.addEventListener('touchmove', onMove, { passive: false });
el.addEventListener('touchend', finish, { passive: true });
el.addEventListener('touchcancel', cancel, { passive: true });
return () => {
el.removeEventListener('touchstart', onStart);
el.removeEventListener('touchmove', onMove);
el.removeEventListener('touchend', finish);
el.removeEventListener('touchcancel', cancel);
};
}, []);
return (
<div ref={containerRef} className="relative h-full w-full overflow-hidden">
<div ref={activeRef} className="absolute inset-0 will-change-transform">
{renderTab(activeTab, false)}
</div>
{peek && (
<div
ref={peekRef}
className="absolute inset-0 will-change-transform"
style={{ transform: `translate3d(${baseOffset(peek.dir)},0,0)` }}
>
{renderTab(peek.tab, true)}
</div>
)}
</div>
);
}