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

This commit is contained in:
oss-sync
2026-06-29 01:32:25 +00:00
parent b857c33ef6
commit a6d1879d63
75 changed files with 3874 additions and 334 deletions
@@ -0,0 +1,349 @@
import { useState } from 'react';
import * as Dialog from '@radix-ui/react-dialog';
import { useQuery, useMutation } from '@tanstack/react-query';
import { useSpaces } from '../../hooks/useSpaces';
// ── API types ─────────────────────────────────────────────────────────────
interface Candidate {
type: 'mcp' | 'ssh';
id: string;
name: string;
detail: string;
willSkip: boolean;
skipReason?: string;
}
interface CandidatesResponse {
mcp: Candidate[];
ssh: Candidate[];
}
interface TransferResult {
copied: { type: string; id: string; name: string }[];
skipped: { type: string; name: string; reason: string }[];
}
// ── API helpers ───────────────────────────────────────────────────────────
async function fetchCandidates(
targetSpaceId: string,
sourceId: string,
): Promise<CandidatesResponse> {
const res = await fetch(
`/api/local/spaces/${encodeURIComponent(targetSpaceId)}/transfer/candidates?sourceId=${encodeURIComponent(sourceId)}`,
{ credentials: 'include' },
);
if (res.status === 403) {
const err = new Error('403');
err.name = 'ForbiddenError';
throw err;
}
if (!res.ok) throw new Error(`Failed to fetch candidates: ${res.status}`);
return (await res.json()) as CandidatesResponse;
}
async function postTransfer(
targetSpaceId: string,
body: { sourceId: string; mcpServerIds: string[]; sshConnectionIds: string[] },
): Promise<TransferResult> {
const res = await fetch(
`/api/local/spaces/${encodeURIComponent(targetSpaceId)}/transfer`,
{
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
},
);
if (!res.ok) {
const text = await res.text().catch(() => '');
let msg = `HTTP ${res.status}`;
try { const j = JSON.parse(text); if (j?.error) msg = j.error; } catch { /* ignore */ }
throw new Error(msg);
}
return (await res.json()) as TransferResult;
}
// ── Props ─────────────────────────────────────────────────────────────────
interface ImportFromWorkspaceDialogProps {
targetSpaceId: string;
kind: 'mcp' | 'ssh';
open: boolean;
onClose(): void;
onImported(result: TransferResult): void;
}
// ── Component ─────────────────────────────────────────────────────────────
export function ImportFromWorkspaceDialog({
targetSpaceId,
kind,
open,
onClose,
onImported,
}: ImportFromWorkspaceDialogProps) {
const { data: allSpaces, isLoading: spacesLoading } = useSpaces();
const [sourceId, setSourceId] = useState<string>('');
const [checked, setChecked] = useState<Set<string>>(new Set());
const [transferError, setTransferError] = useState<string | null>(null);
// Exclude the target space from the source options.
const sourceOptions = (allSpaces ?? []).filter(s => s.id !== targetSpaceId && s.status !== 'archived');
// Fetch candidates whenever a source is selected.
const {
data: candidates,
isLoading: candidatesLoading,
error: candidatesError,
} = useQuery({
queryKey: ['transfer-candidates', targetSpaceId, sourceId],
queryFn: () => fetchCandidates(targetSpaceId, sourceId),
enabled: !!sourceId,
staleTime: 15_000,
});
const isForbidden =
candidatesError instanceof Error && candidatesError.name === 'ForbiddenError';
// The list to display based on kind.
const items: Candidate[] = candidates ? candidates[kind] : [];
const transferMut = useMutation({
mutationFn: (ids: string[]) => {
const body =
kind === 'mcp'
? { sourceId, mcpServerIds: ids, sshConnectionIds: [] }
: { sourceId, mcpServerIds: [], sshConnectionIds: ids };
return postTransfer(targetSpaceId, body);
},
onSuccess: (result) => {
onImported(result);
onClose();
},
onError: (err) => {
setTransferError(err instanceof Error ? err.message : String(err));
},
});
const handleToggle = (id: string) => {
setChecked(prev => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const handleSourceChange = (id: string) => {
setSourceId(id);
setChecked(new Set());
setTransferError(null);
};
const handleImport = () => {
setTransferError(null);
transferMut.mutate(Array.from(checked));
};
// Reset state when dialog opens/closes.
const handleOpenChange = (isOpen: boolean) => {
if (!isOpen) {
setSourceId('');
setChecked(new Set());
setTransferError(null);
onClose();
}
};
const checkedCount = checked.size;
const canImport = checkedCount > 0 && !transferMut.isPending;
const kindLabel = kind === 'mcp' ? 'MCP サーバー' : 'SSH 接続';
return (
<Dialog.Root open={open} onOpenChange={handleOpenChange}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-slate-900/50 z-30" />
<Dialog.Content
className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-surface rounded-2xl shadow-2xl w-full overflow-auto z-40 focus:outline-none"
style={{ maxWidth: 'min(520px, 94vw)', maxHeight: '88dvh' }}
>
<div className="p-5">
{/* Header */}
<div className="flex items-start justify-between gap-3 mb-5">
<div>
<Dialog.Title className="text-xl font-extrabold text-slate-900 m-0">
</Dialog.Title>
<Dialog.Description className="mt-1 text-[13px] text-slate-500">
{kindLabel}
</Dialog.Description>
</div>
<Dialog.Close asChild>
<button
aria-label="閉じる"
className="p-1.5 rounded-lg text-slate-400 hover:text-slate-600 hover:bg-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-ring"
>
<svg className="w-4 h-4" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<path d="M4 4l8 8M12 4l-8 8" />
</svg>
</button>
</Dialog.Close>
</div>
{/* Source workspace selector */}
<div className="flex flex-col gap-1 mb-4">
<label className="text-xs font-semibold text-slate-600">
</label>
{spacesLoading ? (
<div className="text-xs text-slate-400"></div>
) : sourceOptions.length === 0 ? (
<div className="text-xs text-slate-400">
</div>
) : (
<select
value={sourceId}
onChange={e => handleSourceChange(e.target.value)}
className="rounded-md border border-hairline bg-canvas px-3 py-2 text-sm text-slate-800 focus:outline-none focus:ring-2 focus:ring-accent-ring"
>
<option value="">-- --</option>
{sourceOptions.map(s => (
<option key={s.id} value={s.id}>
{s.title}
</option>
))}
</select>
)}
</div>
{/* Candidates list */}
{sourceId && (
<div className="mb-4">
{candidatesLoading && (
<div className="text-xs text-slate-400 py-3"></div>
)}
{isForbidden && (
<div className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700">
</div>
)}
{candidatesError && !isForbidden && (
<div className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700">
: {(candidatesError as Error).message}
</div>
)}
{!candidatesLoading && !candidatesError && items.length === 0 && (
<div className="text-xs text-slate-400 py-3 text-center">
{kindLabel}
</div>
)}
{!candidatesLoading && !candidatesError && items.length > 0 && (
<div>
<div className="text-xs font-semibold text-slate-600 mb-2">{kindLabel}</div>
<ul className="divide-y divide-hairline border border-hairline rounded-md overflow-hidden">
{items.map(item => (
<li
key={item.id}
className={`flex items-start gap-3 px-3 py-2.5 ${
item.willSkip ? 'opacity-60 bg-slate-50' : 'bg-canvas'
}`}
>
<input
type="checkbox"
id={`candidate-${item.id}`}
checked={checked.has(item.id)}
onChange={() => handleToggle(item.id)}
disabled={item.willSkip}
className="mt-0.5 h-4 w-4 flex-shrink-0 rounded border-hairline text-accent focus:ring-accent-ring disabled:cursor-not-allowed"
/>
<label
htmlFor={`candidate-${item.id}`}
className={`flex-1 min-w-0 ${item.willSkip ? 'cursor-not-allowed' : 'cursor-pointer'}`}
>
<div className="flex items-center gap-2 flex-wrap">
<span className="text-[13px] font-medium text-slate-800">
{item.name}
</span>
{item.willSkip && (
<span
title={item.skipReason ?? 'すでに存在します'}
className="inline-block px-1.5 py-0.5 rounded text-[10px] font-medium bg-amber-100 text-amber-700 leading-none cursor-help"
>
</span>
)}
</div>
<div className="text-2xs text-slate-500 mt-0.5 font-mono truncate">
{item.detail}
</div>
</label>
</li>
))}
</ul>
{/* Select all / deselect */}
{items.some(i => !i.willSkip) && (
<div className="mt-1.5 flex gap-3">
<button
type="button"
onClick={() => setChecked(new Set(items.filter(i => !i.willSkip).map(i => i.id)))}
className="text-2xs text-accent hover:underline"
>
</button>
<button
type="button"
onClick={() => setChecked(new Set())}
className="text-2xs text-slate-500 hover:underline"
>
</button>
</div>
)}
</div>
)}
</div>
)}
{/* Transfer error */}
{transferError && (
<div className="mb-3 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700">
: {transferError}
</div>
)}
{/* Footer buttons */}
<div className="flex justify-end gap-2 pt-1">
<Dialog.Close asChild>
<button
type="button"
className="rounded-md border border-hairline px-4 py-2 text-sm font-semibold text-slate-700 transition-colors hover:bg-surface-2"
>
</button>
</Dialog.Close>
<button
type="button"
onClick={handleImport}
disabled={!canImport}
className="rounded-md bg-accent px-4 py-2 text-sm font-bold text-accent-fg transition-colors hover:opacity-90 disabled:opacity-50"
>
{transferMut.isPending
? '取り込み中…'
: `取り込む${checkedCount > 0 ? ` (${checkedCount})` : ''}`}
</button>
</div>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}
+26 -8
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useSpaces, useArchiveSpace } from '../../hooks/useSpaces';
@@ -425,21 +425,39 @@ function SpaceChat({
const qc = useQueryClient();
const auth = useAuthState();
const { data: allTasks } = useLocalTaskList();
const { data: spaces } = useSpaces();
const [showCreate, setShowCreate] = useState(false);
const { search: searchQuery, status: selectedStatus, sort: sortMode, scope } = filter;
const setScope = (val: TaskScope) => onFilterChange({ scope: val });
const setSearchQuery = (val: string) => onFilterChange({ search: val });
const setSelectedStatus = (val: 'all' | StatusColumn) => onFilterChange({ status: val });
const setSortMode = (val: SortMode) => onFilterChange({ sort: val });
const spaceTasks = filterTasksForSpace(allTasks ?? [], spaceId, isPersonalSpace);
// 自分/他メンバーの切替。共有ワークスペースで「他の人のタスク」が存在するときだけ
// 出す(個人ワークスペースや単独利用では意味がないので隠す)。スコープ分割は Tasks
// ページと同じ filterTasksByScope を再利用する(owner_id null は others 側、という
// 既存規約に合わせる)。フィルタ状態は URL に永続化されるため、スペース切替時は App の
// onSelectSpace が search/status/sort/scope を明示リセットする(remount 依存ではない)。
const userId = auth.mode === 'authenticated' ? auth.user.id : null;
const hasOthersTasks = userId != null && spaceTasks.some(t => t.ownerId !== userId);
// 認証無効 (no-auth 単独利用) は admin 同等に扱う(App.tsx の isAdmin と同じ規約)。
const isAdmin = auth.mode === 'authenticated' ? auth.user.role === 'admin' : true;
// case スペースの id 集合。個人バケツ判定(null か case でない id = 誰かの個人
// スペース)に使う。admin の listSpaces は全 case スペースを返すので集合は完全。
const caseSpaceIds = useMemo(
() => new Set((spaces ?? []).filter(s => s.kind === 'case').map(s => s.id)),
[spaces],
);
let spaceTasks = filterTasksForSpace(allTasks ?? [], spaceId, isPersonalSpace, caseSpaceIds);
// 可視性ルール: 個人ワークスペースは本人専用。admin だけが他ユーザーの個人コンテンツを
// 「他のメンバー」タブで監視できる。非 admin の個人スペースは自分の所有分に絞り、他人を
// 一切出さない(リスト API が public/org 等を返しても個人 WS には混ぜない)。
if (isPersonalSpace && !isAdmin && userId != null) {
spaceTasks = spaceTasks.filter(t => t.ownerId === userId);
}
// 自分/他メンバーの切替。共有ワークスペースのメンバー間、または個人スペースを admin が
// 監視するときに「他の人のタスク」が存在する場合だけ出す。スコープ分割は Tasks ページと
// 同じ filterTasksByScope を再利用する(owner_id null は others 側、という既存規約に合わ
// せる)。フィルタ状態は URL に永続化されるため、スペース切替時は App の onSelectSpace が
// search/status/sort/scope を明示リセットする(remount 依存ではない)。
const othersAllowed = !isPersonalSpace || isAdmin;
const hasOthersTasks = othersAllowed && userId != null && spaceTasks.some(t => t.ownerId !== userId);
const tasks = userId != null && hasOthersTasks
? filterTasksByScope(spaceTasks, scope, userId)
: spaceTasks;
+1 -1
View File
@@ -95,7 +95,7 @@ export function SpaceRail({ selectedId, onSelect }: SpaceRailProps) {
space={s}
active={s.id === selectedId}
onSelect={onSelect}
runningCount={countRunningTasksForSpace(tasks ?? [], s)}
runningCount={countRunningTasksForSpace(tasks ?? [], s, myUserId)}
mine={hasOthersSpaces && myUserId != null && s.ownerId === myUserId}
/>
))}
@@ -28,6 +28,7 @@ const SENSITIVE_NOTE_KEYS: Record<string, string> = {
ssh: 'tools.sensitiveNote.ssh',
browser: 'tools.sensitiveNote.browser',
Bash: 'tools.sensitiveNote.Bash',
SpawnSubTask: 'tools.sensitiveNote.SpawnSubTask',
};
function errMsg(e: unknown): string {