feat: add CLI session sharing functionality

- Implemented share token creation and revocation for CLI sessions.
- Added a new page for viewing shared CLI sessions with SSE support.
- Introduced hooks for fetching and managing CLI session shares.
- Enhanced the IssueTerminalTab component to handle share tokens and display active shares.
- Updated API routes to support fetching and revoking share tokens.
- Added unit tests for the CLI session share manager and rate limiter.
- Updated localization files to include new strings for sharing functionality.
This commit is contained in:
catlog22
2026-02-09 22:57:05 +08:00
parent 362f354f1c
commit d0cdee2e68
18 changed files with 748 additions and 23 deletions

View File

@@ -17,6 +17,8 @@ import {
closeCliSession,
createCliSession,
createCliSessionShareToken,
fetchCliSessionShares,
revokeCliSessionShareToken,
executeInCliSession,
fetchCliSessionBuffer,
fetchCliSessions,
@@ -55,6 +57,11 @@ export function IssueTerminalTab({ issueId }: { issueId: string }) {
const [prompt, setPrompt] = useState('');
const [isExecuting, setIsExecuting] = useState(false);
const [shareUrl, setShareUrl] = useState<string>('');
const [shareToken, setShareToken] = useState<string>('');
const [shareExpiresAt, setShareExpiresAt] = useState<string>('');
const [shareRecords, setShareRecords] = useState<Array<{ shareToken: string; expiresAt: string; mode: 'read' | 'write' }>>([]);
const [isLoadingShares, setIsLoadingShares] = useState(false);
const [isRevokingShare, setIsRevokingShare] = useState(false);
const terminalHostRef = useRef<HTMLDivElement | null>(null);
const xtermRef = useRef<XTerm | null>(null);
@@ -103,6 +110,39 @@ export function IssueTerminalTab({ issueId }: { issueId: string }) {
setSelectedSessionKey(sessions[sessions.length - 1]?.sessionKey ?? '');
}, [sessions, selectedSessionKey]);
const buildShareLink = (sessionKey: string, token: string): string => {
const url = new URL(window.location.href);
const base = (import.meta.env.BASE_URL ?? '/').replace(/\/$/, '');
url.pathname = `${base}/cli-sessions/share`;
url.search = `sessionKey=${encodeURIComponent(sessionKey)}&shareToken=${encodeURIComponent(token)}`;
return url.toString();
};
const refreshShares = async (sessionKey: string) => {
if (!sessionKey) {
setShareRecords([]);
return;
}
setIsLoadingShares(true);
try {
const r = await fetchCliSessionShares(sessionKey, projectPath || undefined);
setShareRecords(r.shares || []);
} catch {
setShareRecords([]);
} finally {
setIsLoadingShares(false);
}
};
// Refresh share tokens when session changes
useEffect(() => {
setShareUrl('');
setShareToken('');
setShareExpiresAt('');
void refreshShares(selectedSessionKey);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedSessionKey, projectPath]);
// Init xterm
useEffect(() => {
if (!terminalHostRef.current) return;
@@ -282,18 +322,38 @@ export function IssueTerminalTab({ issueId }: { issueId: string }) {
if (!selectedSessionKey) return;
setError(null);
setShareUrl('');
setShareToken('');
setShareExpiresAt('');
try {
const r = await createCliSessionShareToken(selectedSessionKey, { mode: 'read' }, projectPath || undefined);
const url = new URL(window.location.href);
const base = (import.meta.env.BASE_URL ?? '/').replace(/\/$/, '');
url.pathname = `${base}/cli-sessions/share`;
url.search = `sessionKey=${encodeURIComponent(selectedSessionKey)}&shareToken=${encodeURIComponent(r.shareToken)}`;
setShareUrl(url.toString());
setShareUrl(buildShareLink(selectedSessionKey, r.shareToken));
setShareToken(r.shareToken);
setShareExpiresAt(r.expiresAt);
void refreshShares(selectedSessionKey);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
}
};
const handleRevokeShareLink = async (token: string) => {
if (!selectedSessionKey || !token) return;
setIsRevokingShare(true);
setError(null);
try {
await revokeCliSessionShareToken(selectedSessionKey, { shareToken: token }, projectPath || undefined);
if (token === shareToken) {
setShareUrl('');
setShareToken('');
setShareExpiresAt('');
}
void refreshShares(selectedSessionKey);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setIsRevokingShare(false);
}
};
const handleCopyShareLink = async () => {
if (!shareUrl) return;
try {
@@ -352,12 +412,67 @@ export function IssueTerminalTab({ issueId }: { issueId: string }) {
</div>
{shareUrl && (
<div className="flex items-center gap-2">
<Input value={shareUrl} readOnly />
<Button variant="outline" onClick={handleCopyShareLink}>
<Copy className="w-4 h-4 mr-2" />
{formatMessage({ id: 'common.actions.copy' })}
</Button>
<div className="space-y-2">
<div className="flex items-center gap-2">
<Input value={shareUrl} readOnly />
<Button variant="outline" onClick={handleCopyShareLink}>
<Copy className="w-4 h-4 mr-2" />
{formatMessage({ id: 'common.actions.copy' })}
</Button>
<Button
variant="outline"
onClick={() => handleRevokeShareLink(shareToken)}
disabled={isRevokingShare || !shareToken}
>
{formatMessage({ id: 'issues.terminal.session.revokeShare' })}
</Button>
</div>
{shareExpiresAt && (
<div className="text-xs text-muted-foreground font-mono">
{formatMessage({ id: 'issues.terminal.session.expiresAt' })}: {shareExpiresAt}
</div>
)}
</div>
)}
{selectedSessionKey && shareRecords.length > 0 && (
<div className="space-y-2">
<div className="text-xs text-muted-foreground">
{formatMessage({ id: 'issues.terminal.session.activeShares' })}
{isLoadingShares ? '…' : ''}
</div>
<div className="space-y-1">
{shareRecords.map((s) => (
<div key={s.shareToken} className="flex items-center gap-2">
<div className="text-xs font-mono truncate flex-1 min-w-0">
{s.shareToken.slice(0, 6)}{s.shareToken.slice(-6)}
</div>
<div className="text-xs text-muted-foreground font-mono">{s.mode}</div>
<div className="text-xs text-muted-foreground font-mono truncate max-w-[220px]">{s.expiresAt}</div>
<Button
variant="outline"
size="sm"
onClick={async () => {
try {
await navigator.clipboard.writeText(buildShareLink(selectedSessionKey, s.shareToken));
} catch {
// ignore
}
}}
>
{formatMessage({ id: 'common.actions.copy' })}
</Button>
<Button
variant="outline"
size="sm"
disabled={isRevokingShare}
onClick={() => handleRevokeShareLink(s.shareToken)}
>
{formatMessage({ id: 'issues.terminal.session.revokeShare' })}
</Button>
</div>
))}
</div>
</div>
)}