diff --git a/.gitignore b/.gitignore index 3da0266..bf00d93 100644 --- a/.gitignore +++ b/.gitignore @@ -8,9 +8,6 @@ client_secret_*.apps.googleusercontent.com.json node_modules/ *.pyc -# Desktop: Tauri frontend bundle (tauri.conf.json build.frontendDist) -apps/desktop/dist/ - # Tauri app icon pack: generated by `cargo tauri icon apps/desktop/src-tauri/icons/icon.png` # Version control only the 1024×1024 master; regenerate the rest locally or in release builds. apps/desktop/src-tauri/icons/** diff --git a/AGENTS.md b/AGENTS.md index 3f126fb..2467345 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -199,7 +199,7 @@ http://127.0.0.1:9515/mcp ### 图标与前端 dist(本地 / CI) -版本库为减小噪音,**不提交** Tauri 生成的多尺寸图标包,以及 **`apps/desktop/dist/`** 前端打包目录(见根目录 `.gitignore`)。 +版本库为减小噪音,**不提交** Tauri 生成的多尺寸图标包;但 **`apps/desktop/dist/`** 现在作为桌面端前端静态资源目录,**需要提交到版本库**,以保证新机器 clone 后可直接运行 Tauri desktop。 - **图标**:仅跟踪 `apps/desktop/src-tauri/icons/icon.png` 作为源图(建议 **1024×1024** PNG)。检出代码后,若需要完整 `icons/`(例如打包、验证窗口/托盘图标),在 **`apps/desktop/src-tauri`** 下执行: @@ -210,7 +210,7 @@ http://127.0.0.1:9515/mcp 需已安装 **Tauri CLI**(例如 `cargo install tauri-cli`,或与项目一致的 `cargo-tauri` 版本)。 -- **前端 dist**:`tauri.conf.json` 中 `build.frontendDist` 指向 `../dist`。本地或 CI 在运行 `cargo tauri dev` / `cargo tauri build` 前,需先按项目约定生成或同步 **`apps/desktop/dist/`** 内容;流水线构建桌面端时,在 Tauri 步骤之前加入对应的前端产物步骤即可。 +- **前端 dist**:`tauri.conf.json` 中 `build.frontendDist` 指向 `../dist`。当前仓库直接跟踪 **`apps/desktop/dist/`** 下的静态页面资源,因此新机器 clone 后无需额外生成前端产物即可运行 `cargo run -p secrets-desktop`。若后续引入独立前端构建链,再单独把这部分切回构建产物管理。 ## 代码规范 diff --git a/README.md b/README.md index a2c447c..9290a64 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,12 @@ cargo run -p secrets-desktop-daemon cargo run -p secrets-desktop ``` +说明: + +- `apps/desktop/src-tauri/tauri.conf.json` 中 `build.frontendDist` 指向 `apps/desktop/dist` +- 当前仓库会直接提交 `apps/desktop/dist/` 下的桌面端静态资源 +- 因此新机器 clone 后,无需额外前端构建步骤即可启动 desktop + ## 当前能力 - 桌面端使用系统浏览器完成 Google Desktop OAuth 登录 diff --git a/apps/desktop/dist/disable-features.js b/apps/desktop/dist/disable-features.js new file mode 100644 index 0000000..d907376 --- /dev/null +++ b/apps/desktop/dist/disable-features.js @@ -0,0 +1,41 @@ +(() => { + const tauriInvoke = window.__TAURI_INTERNALS__?.invoke; + + // Disable text selection globally, but keep inputs editable. + document.addEventListener("selectstart", (event) => { + const target = event.target; + if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) { + return; + } + event.preventDefault(); + }); + + async function applyProductionGuards() { + if (!tauriInvoke) { + return; + } + + let isDebugBuild = false; + try { + isDebugBuild = await tauriInvoke("is_debug_build"); + } catch { + return; + } + + if (isDebugBuild) { + return; + } + + document.addEventListener("contextmenu", (event) => event.preventDefault()); + document.addEventListener("keydown", (event) => { + if (event.key === "F12") { + event.preventDefault(); + } + if ((event.ctrlKey || event.metaKey) && event.shiftKey && ["I", "C", "J"].includes(event.key.toUpperCase())) { + event.preventDefault(); + } + }); + } + + void applyProductionGuards(); +})(); diff --git a/apps/desktop/dist/favicon.png b/apps/desktop/dist/favicon.png new file mode 100644 index 0000000..35f1555 Binary files /dev/null and b/apps/desktop/dist/favicon.png differ diff --git a/apps/desktop/dist/index.html b/apps/desktop/dist/index.html new file mode 100644 index 0000000..c492f99 --- /dev/null +++ b/apps/desktop/dist/index.html @@ -0,0 +1,279 @@ + + + + + + Secrets + + + + + + + + + + + + + diff --git a/apps/desktop/dist/main.js b/apps/desktop/dist/main.js new file mode 100644 index 0000000..08cef8b --- /dev/null +++ b/apps/desktop/dist/main.js @@ -0,0 +1,1018 @@ +const invoke = window.__TAURI_INTERNALS__?.invoke; + +const loginView = document.getElementById("login-view"); +const appShell = document.getElementById("app-shell"); +const loginButton = document.getElementById("login-button"); +const loginError = document.getElementById("login-error"); +const vaultModal = document.getElementById("vault-modal"); +const vaultModalTitle = document.getElementById("vault-modal-title"); +const vaultModalCopy = document.getElementById("vault-modal-copy"); +const vaultPasswordInput = document.getElementById("vault-password-input"); +const vaultModalError = document.getElementById("vault-modal-error"); +const vaultModalSave = document.getElementById("vault-modal-save"); +const logoutButton = document.getElementById("logout-button"); +const userTrigger = document.getElementById("user-trigger"); +const userMenu = document.getElementById("user-menu"); +const manageDevicesButton = document.getElementById("manage-devices"); +const openMcpModalButton = document.getElementById("open-mcp-modal"); +const deviceModal = document.getElementById("device-modal"); +const deviceList = document.getElementById("device-list"); +const closeDeviceModal = document.getElementById("close-device-modal"); +const mcpModal = document.getElementById("mcp-modal"); +const closeMcpModal = document.getElementById("close-mcp-modal"); +const mcpIntegrationList = document.getElementById("mcp-integration-list"); +const mcpConfig = document.getElementById("mcp-config"); +const copyMcpConfigButton = document.getElementById("copy-mcp-config"); +const copyMcpConfigButtonLabel = copyMcpConfigButton.querySelector(".button-label"); + +const userName = document.getElementById("user-name"); +const userEmail = document.getElementById("user-email"); +const folderList = document.getElementById("folder-list"); +const entryList = document.getElementById("entry-list"); +const detailFolderLabel = document.getElementById("detail-folder-label"); +const entryTitle = document.getElementById("entry-title"); +const metadataList = document.getElementById("metadata-list"); +const secretList = document.getElementById("secret-list"); +const searchInput = document.getElementById("search-input"); +const typeFilter = document.getElementById("type-filter"); +const detailBadge = document.getElementById("detail-badge"); +const editEntryButton = document.getElementById("edit-entry-button"); +const deleteEntryButton = document.getElementById("delete-entry-button"); +const restoreEntryButton = document.getElementById("restore-entry-button"); +const saveEntryButton = document.getElementById("save-entry-button"); +const cancelEditButton = document.getElementById("cancel-edit-button"); +const addSecretButton = document.getElementById("add-secret-button"); +const newEntryButton = document.getElementById("new-entry-button"); +const nameSection = document.getElementById("name-section"); +const nameView = document.getElementById("name-view"); +const nameInput = document.getElementById("name-input"); +const metadataEditor = document.getElementById("metadata-editor"); +const addMetadataButton = document.getElementById("add-metadata-button"); + +const entryModal = document.getElementById("entry-modal"); +const closeEntryModal = document.getElementById("close-entry-modal"); +const entryModalCancel = document.getElementById("entry-modal-cancel"); +const entryModalSave = document.getElementById("entry-modal-save"); +const entryModalFolder = document.getElementById("entry-modal-folder"); +const entryModalTitle = document.getElementById("entry-modal-title"); +const entryModalType = document.getElementById("entry-modal-type"); + +const secretModal = document.getElementById("secret-modal"); +const closeSecretModal = document.getElementById("close-secret-modal"); +const secretModalCancel = document.getElementById("secret-modal-cancel"); +const secretModalSave = document.getElementById("secret-modal-save"); +const secretModalTitle = document.getElementById("secret-modal-title"); +const secretNameInput = document.getElementById("secret-name-input"); +const secretTypeInput = document.getElementById("secret-type-input"); +const secretValueInput = document.getElementById("secret-value-input"); + +const historyModal = document.getElementById("history-modal"); +const closeHistoryModal = document.getElementById("close-history-modal"); +const historyModalCopy = document.getElementById("history-modal-copy"); +const historyList = document.getElementById("history-list"); + +const appState = { + shell: null, + activeFolder: "所有项目", + activeType: "", + query: "", + mcpJson: "", + editing: false, + draftMetadata: [], + revealedSecrets: {}, + secretModalMode: "create", + editingSecretId: null, + historySecretId: null, +}; + +function escapeHtml(value) { + return String(value ?? "") + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function displaySecretName(name) { + const normalized = String(name ?? "") + .trim() + .toLowerCase() + .replaceAll("_", " "); + if (normalized === "api token" || normalized === "token") return "访问令牌"; + return String(name ?? ""); +} + +function integrationGlyph(appName) { + if (appName === "Cursor") return "⌘"; + if (appName === "Claude Code") return ">_"; + return "⌁"; +} + +function selectedEntry() { + return appState.shell?.selected_entry || null; +} + +function resetSecretCache() { + appState.revealedSecrets = {}; +} + +function setSelectedDetail(detail) { + if (!appState.shell) return; + appState.shell.selected_entry_id = detail?.id || null; + appState.shell.selected_entry = detail; + appState.editing = false; + appState.draftMetadata = detail?.metadata ? detail.metadata.map((item) => ({ ...item })) : []; + resetSecretCache(); +} + +function showLogin() { + loginView.classList.remove("hidden"); + appShell.classList.add("hidden"); +} + +function showShell() { + loginView.classList.add("hidden"); + appShell.classList.remove("hidden"); +} + +function setLoginError(message) { + loginError.textContent = message || ""; + loginError.classList.toggle("hidden", !message); +} + +function setVaultModalError(message) { + vaultModalError.textContent = message || ""; + vaultModalError.classList.toggle("hidden", !message); +} + +function openVaultModal({ setup = false } = {}) { + vaultModalTitle.textContent = setup ? "设置本地 Vault 主密码" : "解锁本地 Vault"; + vaultModalCopy.textContent = setup + ? "首次使用,请先设置本地 vault 主密码。" + : "请输入本地 vault 主密码以解锁。"; + vaultPasswordInput.value = ""; + setVaultModalError(""); + vaultModal.classList.remove("hidden"); + setTimeout(() => vaultPasswordInput.focus(), 0); +} + +function closeVaultModal() { + vaultModal.classList.add("hidden"); + vaultPasswordInput.value = ""; + setVaultModalError(""); +} + +function setAvatar() { + const avatar = document.querySelector(".avatar"); + const initial = appState.shell?.user?.name?.trim()?.[0] || "S"; + avatar.textContent = initial.toUpperCase(); +} + +function renderFolders() { + if (!appState.shell) return; + userName.textContent = appState.shell.user.name; + userEmail.textContent = appState.shell.user.email; + setAvatar(); + const activeFolder = appState.activeFolder; + const folders = [ + { + label: "所有项目", + count: appState.shell.folders + .filter((folder) => folder.kind === "folder") + .reduce((sum, folder) => sum + folder.count, 0), + }, + ...appState.shell.folders, + ]; + folderList.innerHTML = folders + .map( + (folder) => ` + + ` + ) + .join(""); +} + +function renderTypeFilter() { + if (!appState.shell) return; + typeFilter.innerHTML = [ + ``, + ...appState.shell.entry_types.map( + (entryType) => + `` + ), + ].join(""); +} + +function renderEntries() { + if (!appState.shell) return; + const entries = appState.shell.entries; + const selectedId = appState.shell.selected_entry_id; + if (!entries.length) { + entryList.innerHTML = ` +
+
没有匹配的条目
+
调整搜索词、类型筛选或文件夹后再试。
+
+ `; + return; + } + entryList.innerHTML = entries + .map( + (entry) => ` + + ` + ) + .join(""); +} + +function renderMetadataEditor() { + metadataEditor.innerHTML = appState.draftMetadata + .map( + (field, index) => ` +
+ + + +
+ ` + ) + .join(""); +} + +function syncEditMode() { + const editing = appState.editing; + const hasSelection = Boolean(selectedEntry()); + const deleted = Boolean(selectedEntry()?.deleted); + editEntryButton.classList.toggle("hidden", editing || !hasSelection || deleted); + saveEntryButton.classList.toggle("hidden", !editing); + cancelEditButton.classList.toggle("hidden", !editing); + nameSection.classList.toggle("hidden", !editing); + nameView.classList.add("hidden"); + nameInput.classList.toggle("hidden", !editing); + metadataList.classList.toggle("hidden", editing); + metadataEditor.classList.toggle("hidden", !editing); + addMetadataButton.classList.toggle("hidden", !editing); + addSecretButton.classList.toggle("hidden", editing || !hasSelection || deleted); +} + +function renderSecretCard(secret) { + const revealed = appState.revealedSecrets[secret.id]; + const isVisible = Boolean(revealed?.visible); + const value = isVisible ? revealed.value : secret.masked_value; + return ` +
+
${escapeHtml(displaySecretName(secret.name))}
+
+
+ +
${escapeHtml(value)}
+ +
+
+ + + + +
+
+
+ `; +} + +function renderDetail() { + const detail = selectedEntry(); + if (!detail) { + detailFolderLabel.textContent = "-"; + entryTitle.textContent = "未选择条目"; + nameView.textContent = "-"; + nameInput.value = ""; + metadataList.innerHTML = `
从左侧列表选择一个条目后,这里会显示结构化元数据。
`; + metadataEditor.innerHTML = ""; + secretList.innerHTML = `
选中条目后会在这里显示受保护的密钥字段。
`; + detailBadge.classList.add("hidden"); + deleteEntryButton.classList.add("hidden"); + restoreEntryButton.classList.add("hidden"); + syncEditMode(); + return; + } + + detailFolderLabel.textContent = detail.folder; + entryTitle.textContent = detail.title; + nameView.textContent = detail.title; + nameInput.value = detail.title; + detailBadge.classList.toggle("hidden", !detail.deleted); + deleteEntryButton.classList.toggle("hidden", detail.deleted); + restoreEntryButton.classList.toggle("hidden", !detail.deleted); + metadataList.innerHTML = detail.metadata.length + ? detail.metadata + .map( + (field) => ` +
+
${escapeHtml(field.label)}
+
${escapeHtml(field.value)}
+
+ ` + ) + .join("") + : `
当前条目还没有元数据。
`; + renderMetadataEditor(); + + secretList.innerHTML = detail.secrets.length + ? detail.secrets.map(renderSecretCard).join("") + : `
当前条目还没有密钥字段。
`; + syncEditMode(); +} + +function renderShell(shell) { + appState.shell = shell; + appState.activeFolder = "所有项目"; + appState.activeType = ""; + appState.query = ""; + appState.editing = false; + appState.draftMetadata = shell.selected_entry?.metadata + ? shell.selected_entry.metadata.map((item) => ({ ...item })) + : []; + resetSecretCache(); + searchInput.value = ""; + renderFolders(); + renderTypeFilter(); + renderEntries(); + renderDetail(); +} + +async function ensureUnlockedShell(data) { + if (!data?.logged_in) return data; + if (data?.shell) return data; + + const vault = data?.vault; + if (!vault) return data; + + return new Promise((resolve, reject) => { + const setup = !vault.has_master_password; + openVaultModal({ setup }); + + const submit = async () => { + const password = vaultPasswordInput.value.trim(); + if (!password) { + setVaultModalError(setup ? "请先设置本地 vault 主密码" : "请输入本地 vault 主密码"); + return; + } + + vaultModalSave.disabled = true; + try { + if (setup) { + await invoke("setup_master_password", { password }); + } else { + await invoke("unlock_vault", { password }); + } + cleanup(); + closeVaultModal(); + const bootstrapped = await invoke("app_bootstrap"); + resolve(bootstrapped); + } catch (error) { + setVaultModalError(String(error)); + } finally { + vaultModalSave.disabled = false; + } + }; + + const onClick = () => { + void submit(); + }; + const onKeydown = (event) => { + if (event.key !== "Enter") return; + event.preventDefault(); + void submit(); + }; + const cleanup = () => { + vaultModalSave.removeEventListener("click", onClick); + vaultPasswordInput.removeEventListener("keydown", onKeydown); + }; + + vaultModalSave.addEventListener("click", onClick); + vaultPasswordInput.addEventListener("keydown", onKeydown); + }); +} + +function renderMcpDialog(data) { + appState.mcpJson = data.mcp_json; + mcpIntegrationList.innerHTML = data.integrations + .map( + (integration) => ` +
+
+ +
+
${escapeHtml(integration.app_name)}
+
+
+ +
+ ` + ) + .join(""); + mcpConfig.textContent = data.mcp_json; +} + +function closeEntryModalPanel() { + entryModal.classList.add("hidden"); + entryModalFolder.value = ""; + entryModalTitle.value = ""; + entryModalType.value = ""; +} + +function openEntryModalPanel() { + const detail = selectedEntry(); + entryModalFolder.value = detail?.folder || "Refining"; + entryModalType.value = detail?.entry_type || "service"; + entryModalTitle.value = ""; + entryModal.classList.remove("hidden"); +} + +function closeSecretModalPanel() { + secretModal.classList.add("hidden"); + appState.secretModalMode = "create"; + appState.editingSecretId = null; + secretNameInput.value = ""; + secretTypeInput.value = "text"; + secretValueInput.value = ""; +} + +function openSecretModalPanel(mode, options = {}) { + appState.secretModalMode = mode; + appState.editingSecretId = options.secretId || null; + secretModalTitle.textContent = mode === "edit" ? "编辑密钥" : "新增密钥"; + secretNameInput.value = options.name || ""; + secretTypeInput.value = options.secretType || "text"; + secretValueInput.value = options.value || ""; + secretModal.classList.remove("hidden"); +} + +function closeHistoryModalPanel() { + historyModal.classList.add("hidden"); + appState.historySecretId = null; + historyList.innerHTML = ""; +} + +function setTransientLabel(labelNode, nextText, originalText) { + if (!labelNode) return; + labelNode.textContent = nextText; + setTimeout(() => { + labelNode.textContent = originalText; + }, 1200); +} + +async function bootstrap() { + if (!invoke) { + showLogin(); + return; + } + + try { + let data = await invoke("app_bootstrap"); + if (!data.logged_in) { + showLogin(); + return; + } + data = await ensureUnlockedShell(data); + + renderShell(data.shell); + showShell(); + } catch (error) { + setLoginError(String(error)); + showLogin(); + } +} + +async function doDemoLogin() { + if (!invoke) return; + setLoginError(""); + loginButton.disabled = true; + try { + let data = await invoke("continue_demo_login"); + data = await ensureUnlockedShell(data); + renderShell(data.shell); + showShell(); + } catch (error) { + setLoginError(String(error)); + } finally { + loginButton.disabled = false; + } +} + +async function doLogout() { + if (!invoke) return; + await invoke("logout"); + userMenu.classList.add("hidden"); + appState.shell = null; + showLogin(); +} + +async function loadEntryDetail(entryId) { + const detail = await invoke("entry_detail", { entryId }); + setSelectedDetail(detail); + renderEntries(); + renderDetail(); +} + +async function refreshEntries() { + if (!appState.shell) return; + const deletedOnly = appState.activeFolder === "最近删除"; + const folder = + appState.activeFolder === "所有项目" || deletedOnly + ? null + : appState.activeFolder; + const query = appState.query.trim() || null; + const entries = await invoke("list_entries", { + query: { + folder, + entryType: appState.activeType || null, + query, + deletedOnly, + }, + }); + appState.shell.entries = entries; + renderEntries(); + const nextSelected = entries.find((entry) => entry.id === appState.shell.selected_entry_id) || entries[0]; + if (!nextSelected) { + setSelectedDetail(null); + renderDetail(); + return; + } + if (!selectedEntry() || nextSelected.id !== appState.shell.selected_entry_id) { + await loadEntryDetail(nextSelected.id); + return; + } + renderDetail(); +} + +async function refreshShell() { + const data = await invoke("app_bootstrap"); + if (!data.logged_in || !data.shell) return; + const previousFolder = appState.activeFolder; + const previousType = appState.activeType; + const previousQuery = appState.query; + const previousSelectedId = appState.shell?.selected_entry_id; + renderShell(data.shell); + appState.activeFolder = previousFolder; + appState.activeType = previousType; + appState.query = previousQuery; + renderFolders(); + renderTypeFilter(); + await refreshEntries(); + if (previousSelectedId && appState.shell.entries.some((entry) => entry.id === previousSelectedId)) { + await loadEntryDetail(previousSelectedId); + } +} + +function applyUpdatedDetail(detail) { + if (!appState.shell) return; + const current = appState.shell.entries.find((entry) => entry.id === detail.id); + if (current) { + current.title = detail.title; + current.subtitle = detail.entry_type; + current.folder = detail.folder; + } + setSelectedDetail(detail); + renderEntries(); + renderDetail(); +} + +async function openDevices() { + if (!invoke) return; + const devices = await invoke("device_list"); + deviceList.innerHTML = devices.length + ? devices + .map( + (device) => ` +
+
${escapeHtml(device.name)}
+
${escapeHtml(device.platform)} · ${escapeHtml(device.client_version)}
+
${escapeHtml(device.last_seen)}
+ ${ + device.ip + ? `
IP · ${escapeHtml(device.ip)}
` + : "" + } +
+ ` + ) + .join("") + : `
当前没有在线设备。
`; + deviceModal.classList.remove("hidden"); + userMenu.classList.add("hidden"); +} + +async function openMcpDialog() { + if (!invoke) return; + const data = await invoke("mcp_dialog_data"); + renderMcpDialog(data); + mcpModal.classList.remove("hidden"); +} + +async function ensureSecretValue(secretId, { visible = false } = {}) { + const existing = appState.revealedSecrets[secretId]; + if (existing) { + if (visible) existing.visible = true; + return existing; + } + const revealed = await invoke("reveal_secret_value", { secretId }); + appState.revealedSecrets[secretId] = { ...revealed, visible }; + return appState.revealedSecrets[secretId]; +} + +async function toggleSecretVisibility(secretId) { + const existing = appState.revealedSecrets[secretId]; + if (existing?.visible) { + existing.visible = false; + } else { + await ensureSecretValue(secretId, { visible: true }); + } + renderDetail(); +} + +async function copySecretValue(secretId, button) { + const revealed = await ensureSecretValue(secretId); + await navigator.clipboard.writeText(revealed.value); + setTransientLabel(button?.querySelector(".button-label"), "已复制", "复制"); +} + +async function beginEditSecret(secretId) { + const detail = selectedEntry(); + const secret = detail?.secrets.find((item) => item.id === secretId); + if (!secret) return; + const revealed = await ensureSecretValue(secretId); + openSecretModalPanel("edit", { + secretId, + name: secret.name, + secretType: secret.secret_type, + value: revealed.value, + }); +} + +async function openSecretHistory(secretId) { + const detail = selectedEntry(); + const secret = detail?.secrets.find((item) => item.id === secretId); + if (!secret) return; + const history = await invoke("secret_history", { secretId }); + appState.historySecretId = secretId; + historyModalCopy.textContent = `查看 ${secret.name} 的历史版本并选择回滚目标。`; + historyList.innerHTML = history.length + ? history + .map( + (item) => ` +
+
+
v${item.version} · ${escapeHtml(item.action)}
+
${escapeHtml(item.created_at)}
+
+
+ 名称:${escapeHtml(item.name)} + 类型:${escapeHtml(item.secret_type)} + history_id:${item.history_id} +
+
${escapeHtml(item.value)}
+
+ +
+
+ ` + ) + .join("") + : `
当前密钥还没有历史记录。
`; + historyModal.classList.remove("hidden"); +} + +async function saveSecretModalState() { + const detail = selectedEntry(); + if (!detail) return; + const payload = { + name: secretNameInput.value.trim(), + secretType: secretTypeInput.value || "text", + value: secretValueInput.value, + }; + if (!payload.name || !payload.value) { + window.alert("请填写完整的密钥名称和内容。"); + return; + } + if (appState.secretModalMode === "edit" && appState.editingSecretId) { + const updated = await invoke("update_secret", { + secret: { + id: appState.editingSecretId, + name: payload.name, + secretType: payload.secretType, + value: payload.value, + }, + }); + closeSecretModalPanel(); + applyUpdatedDetail(updated); + return; + } + const created = await invoke("create_secret", { + entryId: detail.id, + secret: payload, + }); + closeSecretModalPanel(); + applyUpdatedDetail(created); +} + +async function createNewEntry() { + const folder = entryModalFolder.value.trim(); + const title = entryModalTitle.value.trim(); + const entryType = entryModalType.value.trim() || "service"; + if (!folder || !title) { + window.alert("请填写项目和名称。"); + return; + } + const detail = await invoke("create_entry", { + entry: { + folder, + title, + entryType, + metadata: [], + secrets: [], + }, + }); + closeEntryModalPanel(); + appState.activeFolder = folder; + await refreshShell(); + await loadEntryDetail(detail.id); +} + +folderList.addEventListener("click", async (event) => { + const button = event.target.closest("[data-folder]"); + if (!button || !appState.shell) return; + appState.activeFolder = button.dataset.folder; + appState.editing = false; + renderFolders(); + await refreshEntries(); +}); + +entryList.addEventListener("click", async (event) => { + const button = event.target.closest("[data-entry-id]"); + if (!button) return; + const { entryId } = button.dataset; + if (!entryId || entryId === appState.shell?.selected_entry_id) return; + await loadEntryDetail(entryId); +}); + +searchInput.addEventListener("input", async () => { + appState.query = searchInput.value; + await refreshEntries(); +}); + +typeFilter.addEventListener("change", async () => { + appState.activeType = typeFilter.value; + await refreshEntries(); +}); + +editEntryButton.addEventListener("click", () => { + const detail = selectedEntry(); + if (!detail) return; + appState.editing = true; + appState.draftMetadata = detail.metadata.map((item) => ({ ...item })); + renderDetail(); +}); + +cancelEditButton.addEventListener("click", () => { + appState.editing = false; + appState.draftMetadata = selectedEntry()?.metadata.map((item) => ({ ...item })) || []; + renderDetail(); +}); + +metadataEditor.addEventListener("input", (event) => { + const target = event.target; + const keyIndex = target.dataset.metadataKey; + const valueIndex = target.dataset.metadataValue; + if (keyIndex !== undefined) { + appState.draftMetadata[Number(keyIndex)].label = target.value; + } + if (valueIndex !== undefined) { + appState.draftMetadata[Number(valueIndex)].value = target.value; + } +}); + +metadataEditor.addEventListener("click", (event) => { + const button = event.target.closest("[data-remove-metadata]"); + if (!button) return; + const index = Number(button.dataset.removeMetadata); + appState.draftMetadata.splice(index, 1); + renderMetadataEditor(); +}); + +addMetadataButton.addEventListener("click", () => { + appState.draftMetadata.push({ label: "", value: "" }); + renderMetadataEditor(); +}); + +saveEntryButton.addEventListener("click", async () => { + const detail = selectedEntry(); + if (!detail) return; + try { + const updated = await invoke("update_entry_detail", { + entry: { + ...detail, + title: nameInput.value.trim(), + metadata: appState.draftMetadata.filter((item) => item.label.trim()), + }, + }); + applyUpdatedDetail(updated); + } catch (error) { + window.alert(String(error)); + } +}); + +deleteEntryButton.addEventListener("click", async () => { + if (!appState.shell?.selected_entry_id) return; + if (!window.confirm("确定要将当前条目移到最近删除吗?")) return; + try { + await invoke("delete_entry", { entryId: appState.shell.selected_entry_id }); + appState.editing = false; + await refreshShell(); + await refreshEntries(); + } catch (error) { + window.alert(String(error)); + } +}); + +restoreEntryButton.addEventListener("click", async () => { + if (!appState.shell?.selected_entry_id) return; + try { + await invoke("restore_deleted_entry", { entryId: appState.shell.selected_entry_id }); + appState.activeFolder = "所有项目"; + appState.editing = false; + await refreshShell(); + await refreshEntries(); + } catch (error) { + window.alert(String(error)); + } +}); + +addSecretButton.addEventListener("click", () => { + if (!selectedEntry()) return; + openSecretModalPanel("create"); +}); + +newEntryButton.addEventListener("click", () => { + openEntryModalPanel(); +}); + +loginButton.addEventListener("click", doDemoLogin); +logoutButton.addEventListener("click", doLogout); +manageDevicesButton.addEventListener("click", openDevices); +openMcpModalButton.addEventListener("click", openMcpDialog); +userTrigger.addEventListener("click", () => userMenu.classList.toggle("hidden")); +closeDeviceModal.addEventListener("click", () => deviceModal.classList.add("hidden")); +closeMcpModal.addEventListener("click", () => mcpModal.classList.add("hidden")); +closeEntryModal.addEventListener("click", closeEntryModalPanel); +entryModalCancel.addEventListener("click", closeEntryModalPanel); +entryModalSave.addEventListener("click", async () => { + try { + await createNewEntry(); + } catch (error) { + window.alert(String(error)); + } +}); +closeSecretModal.addEventListener("click", closeSecretModalPanel); +secretModalCancel.addEventListener("click", closeSecretModalPanel); +secretModalSave.addEventListener("click", async () => { + try { + await saveSecretModalState(); + } catch (error) { + window.alert(String(error)); + } +}); +closeHistoryModal.addEventListener("click", closeHistoryModalPanel); + +deviceModal.addEventListener("click", (event) => { + if (event.target === deviceModal) { + deviceModal.classList.add("hidden"); + } +}); +mcpModal.addEventListener("click", (event) => { + if (event.target === mcpModal) { + mcpModal.classList.add("hidden"); + } +}); +entryModal.addEventListener("click", (event) => { + if (event.target === entryModal) { + closeEntryModalPanel(); + } +}); +secretModal.addEventListener("click", (event) => { + if (event.target === secretModal) { + closeSecretModalPanel(); + } +}); +historyModal.addEventListener("click", (event) => { + if (event.target === historyModal) { + closeHistoryModalPanel(); + } +}); + +mcpIntegrationList.addEventListener("click", async (event) => { + const button = event.target.closest("[data-apply-target]"); + if (!button || !invoke) return; + button.disabled = true; + button.classList.add("is-loading"); + try { + const result = await invoke("apply_mcp_config", { + target: button.dataset.applyTarget, + }); + renderMcpDialog(result.data); + } finally { + button.disabled = false; + button.classList.remove("is-loading"); + } +}); + +secretList.addEventListener("click", async (event) => { + const button = event.target.closest("[data-secret-action]"); + if (!button) return; + const secretId = button.dataset.secretId; + const action = button.dataset.secretAction; + if (!secretId) return; + try { + if (action === "toggle") { + await toggleSecretVisibility(secretId); + return; + } + if (action === "copy") { + await copySecretValue(secretId, button); + return; + } + if (action === "edit") { + await beginEditSecret(secretId); + return; + } + if (action === "history") { + await openSecretHistory(secretId); + return; + } + if (action === "delete") { + if (!window.confirm("确定要删除这个密钥吗?")) return; + await invoke("delete_secret", { secretId }); + await loadEntryDetail(selectedEntry().id); + } + } catch (error) { + window.alert(String(error)); + } +}); + +historyList.addEventListener("click", async (event) => { + const button = event.target.closest("[data-history-version]"); + if (!button || !appState.historySecretId) return; + if (!window.confirm(`确定回滚到 v${button.dataset.historyVersion} 吗?`)) return; + try { + const detail = await invoke("rollback_secret", { + secretId: appState.historySecretId, + version: Number(button.dataset.historyVersion), + historyId: Number(button.dataset.historyId), + }); + closeHistoryModalPanel(); + applyUpdatedDetail(detail); + } catch (error) { + window.alert(String(error)); + } +}); + +copyMcpConfigButton.addEventListener("click", async () => { + if (!appState.mcpJson) return; + await navigator.clipboard.writeText(appState.mcpJson); + copyMcpConfigButtonLabel.textContent = "已复制"; + setTimeout(() => { + copyMcpConfigButtonLabel.textContent = "复制"; + }, 1200); +}); + +document.addEventListener("click", (event) => { + if (!userTrigger.contains(event.target) && !userMenu.contains(event.target)) { + userMenu.classList.add("hidden"); + } +}); + +bootstrap(); diff --git a/apps/desktop/dist/styles.css b/apps/desktop/dist/styles.css new file mode 100644 index 0000000..e978329 --- /dev/null +++ b/apps/desktop/dist/styles.css @@ -0,0 +1,1072 @@ +* { + box-sizing: border-box; +} + +:root { + color-scheme: dark; + --bg-app: #0a0a0b; + --bg-panel: #111113; + --bg-elevated: #17171a; + --bg-hover: #1d1d22; + --bg-input: #141418; + --border-subtle: #26262c; + --border-strong: #34343d; + --text-primary: #f5f5f7; + --text-secondary: #b3b3bd; + --text-tertiary: #7c7c88; + --accent-blue: #3b82f6; + --accent-blue-hover: #4c8dff; + --accent-red: #ef4444; + --accent-amber: #d97706; + --accent-purple-muted: #2a2440; + --accent-purple-text: #a5b4fc; + --shadow-panel: 0 16px 32px rgba(0, 0, 0, 0.34); + --shadow-modal: 0 24px 48px rgba(0, 0, 0, 0.45); + --radius-panel: 20px; + --radius-control: 14px; +} + +html, +body { + min-height: 100%; + user-select: none; + -webkit-user-select: none; + -webkit-user-drag: none; +} + +body { + margin: 0; + font-family: Inter, "SF Pro Text", "SF Pro Display", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + background: var(--bg-app); + color: var(--text-primary); +} + +button, +input, +select { + font: inherit; +} + +button { + color: inherit; +} + +button, +input, +select, +pre { + transition: + border-color 140ms ease, + background-color 140ms ease, + color 140ms ease, + box-shadow 140ms ease, + opacity 140ms ease; +} + +button:focus-visible, +input:focus-visible, +select:focus-visible { + outline: none; + border-color: var(--accent-blue); + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.16); +} + +.hidden { + display: none !important; +} + +.subtle { + color: var(--text-tertiary); +} + +.login-screen { + min-height: 100vh; + display: grid; + grid-template-rows: 38px minmax(0, 1fr); + background: var(--bg-panel); + position: relative; +} + +.window-titlebar { + height: 38px; +} + +.login-titlebar, +.shell-titlebar { + width: 100%; +} + +.login-card { + width: min(100%, 420px); + display: flex; + flex-direction: column; + align-items: center; + gap: 24px; + margin: auto; + padding: 48px 40px 34px; + background: transparent; + border: none; + box-shadow: none; +} + +.login-main { + width: 100%; + display: flex; + flex-direction: column; + align-items: center; + gap: 24px; +} + +.login-emblem { + width: 56px; + height: 56px; + display: grid; + place-items: center; + background: var(--bg-elevated); + border: 1px solid var(--border-subtle); + border-radius: 999px; +} + +.login-lock-icon { + width: 24px; + height: 24px; + color: var(--text-secondary); + stroke: currentColor; + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; +} + +.login-title-block { + width: 100%; + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; + text-align: center; +} + +.login-title-block h1, +.detail-header h2, +.detail-section h3, +.modal-header h3, +.mcp-json-header h4 { + margin: 0; +} + +.login-title-block h1 { + font-size: 32px; + font-weight: 700; + letter-spacing: -0.02em; +} + +.login-subtle { + width: 100%; + margin: 0; + color: var(--text-secondary); + font-size: 14px; + line-height: 1.45; + font-weight: 500; +} + +.login-actions { + width: 100%; + display: flex; + justify-content: center; +} + +.login-google-button { + width: 100%; + min-height: 40px; + padding: 10px 16px; + justify-content: center; + gap: 10px; + border: 1px solid var(--accent-blue); + background: var(--accent-blue); +} + +.login-google-mark { + width: 18px; + height: 18px; + color: #f5f5f7; + flex: none; +} + +.error-text { + width: 100%; + margin: 0; + color: var(--accent-red); + font-size: 13px; + text-align: center; +} + +.shell { + display: grid; + grid-template-columns: 248px minmax(0, 1fr); + grid-template-rows: 38px minmax(0, 1fr); + min-height: 100vh; + background: var(--bg-app); +} + +.shell-titlebar { + grid-column: 1 / -1; + grid-row: 1; +} + +.sidebar { + grid-column: 1; + grid-row: 2; + display: flex; + flex-direction: column; + gap: 16px; + padding: 20px 16px; + background: var(--bg-panel); + border-right: 1px solid var(--border-subtle); +} + +.sidebar-spacer { + flex: 1; +} + +.user-block { + position: relative; +} + +.user-trigger { + width: 100%; + display: flex; + align-items: center; + gap: 12px; + padding: 14px; + border: 1px solid var(--border-subtle); + border-radius: 16px; + background: var(--bg-elevated); + color: var(--text-primary); + text-align: left; + cursor: pointer; +} + +.avatar { + width: 36px; + height: 36px; + display: grid; + place-items: center; + border-radius: 999px; + background: #d97706; + color: var(--text-primary); + font-size: 14px; + font-weight: 700; +} + +.user-copy { + min-width: 0; + flex: 1; +} + +.user-name { + color: var(--text-primary); + font-size: 14px; + font-weight: 600; +} + +.user-email { + color: var(--text-secondary); + font-size: 12px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.caret { + color: var(--text-tertiary); +} + +.user-menu { + position: absolute; + top: calc(100% + 8px); + left: 0; + width: 100%; + padding: 6px; + background: var(--bg-elevated); + border: 1px solid var(--border-subtle); + border-radius: 14px; + box-shadow: var(--shadow-panel); + z-index: 20; +} + +.menu-item { + width: 100%; + padding: 10px 12px; + border: none; + border-radius: 10px; + background: transparent; + color: var(--text-primary); + text-align: left; + cursor: pointer; +} + +.menu-item:hover { + background: var(--bg-hover); +} + +.menu-item.danger { + color: #f87171; +} + +.folder-list { + display: grid; + gap: 10px; +} + +.folder-item { + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 12px 14px; + border: 1px solid transparent; + border-radius: 14px; + background: transparent; + color: var(--text-secondary); + text-align: left; + cursor: pointer; +} + +.folder-item:hover { + background: rgba(255, 255, 255, 0.02); +} + +.folder-item.active { + background: var(--bg-elevated); + border-color: var(--border-strong); + color: var(--accent-purple-text); + font-weight: 600; +} + +.folder-item.active .folder-count { + background: var(--accent-purple-muted); + color: var(--accent-purple-text); +} + +.folder-count { + min-width: 24px; + padding: 4px 9px; + border-radius: 999px; + background: var(--bg-hover); + color: var(--text-secondary); + font-size: 12px; + text-align: center; +} + +.sidebar-footer { + display: grid; + gap: 8px; +} + +.sidebar-utility { + width: 100%; + display: flex; + align-items: center; + gap: 10px; + padding: 10px; + border: 1px solid var(--border-subtle); + border-radius: 12px; + background: var(--bg-input); + color: var(--text-secondary); + text-align: left; + cursor: pointer; +} + +.sidebar-utility:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + +.sidebar-utility-icon { + width: 18px; + display: inline-flex; + justify-content: center; + color: var(--text-secondary); +} + +.main-shell { + grid-column: 2; + grid-row: 2; + display: grid; + grid-template-columns: 404px minmax(0, 1fr); + min-width: 0; + min-height: 0; +} + +.list-column { + display: flex; + flex-direction: column; + min-width: 0; + background: var(--bg-panel); + border-right: 1px solid var(--border-subtle); +} + +.searchbar-shell, +.list-pane, +.toolbar { + background: var(--bg-panel); +} + +.searchbar-shell { + padding: 18px 18px 14px; + border-bottom: 1px solid var(--border-subtle); +} + +.global-search { + max-width: none; +} + +.search-input, +.filter-select, +.detail-input { + width: 100%; + padding: 12px 14px; + border: 1px solid var(--border-subtle); + border-radius: 14px; + background: var(--bg-input); + color: var(--text-primary); + outline: none; +} + +.search-input::placeholder, +.detail-input::placeholder { + color: var(--text-tertiary); +} + +.filter-select { + appearance: none; +} + +.list-pane { + display: flex; + flex-direction: column; + min-width: 0; + flex: 1; +} + +.toolbar { + padding: 14px 18px; + border-bottom: 1px solid var(--border-subtle); +} + +.entry-list { + flex: 1; + padding: 14px 18px 18px; + display: grid; + gap: 10px; + align-content: start; + overflow: auto; +} + +.entry-item { + width: 100%; + padding: 16px; + border: 1px solid var(--border-subtle); + border-radius: 16px; + background: var(--bg-input); + color: inherit; + text-align: left; + cursor: pointer; +} + +.entry-item:hover { + background: var(--bg-hover); +} + +.entry-item.active { + background: var(--bg-elevated); + border-color: var(--border-strong); +} + +.entry-title { + color: var(--text-primary); + font-size: 14px; + font-weight: 600; +} + +.entry-subtitle, +.entry-folder-chip, +.integration-path { + margin-top: 4px; + color: var(--text-secondary); + font-size: 13px; +} + +.detail-pane { + min-width: 0; + overflow: auto; + padding: 36px; + background: var(--bg-app); +} + +.detail-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; + margin-bottom: 32px; +} + +.detail-title-stack { + display: flex; + flex-direction: column; + gap: 6px; +} + +.detail-folder-label { + color: var(--text-tertiary); + font-size: 12px; + font-weight: 600; +} + +.detail-title-block { + display: flex; + align-items: center; + gap: 10px; +} + +.detail-header h2 { + font-size: 32px; + font-weight: 700; +} + +.detail-badge { + padding: 4px 8px; + border-radius: 999px; + background: #422006; + color: #fbbf24; + font-size: 12px; +} + +.detail-actions { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; +} + +.primary, +.secondary-button { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 10px 14px; + border-radius: 12px; + cursor: pointer; + white-space: nowrap; +} + +.primary { + border: none; + background: var(--accent-blue); + color: var(--text-primary); +} + +.primary:hover { + background: var(--accent-blue-hover); +} + +.secondary-button { + border: 1px solid var(--border-strong); + background: var(--bg-elevated); + color: var(--text-primary); +} + +.secondary-button:hover { + background: var(--bg-hover); +} + +.secondary-button.small, +.primary.small { + padding: 10px 14px; + font-size: 12px; + font-weight: 500; +} + +.secondary-button.large { + padding: 14px 28px; +} + +.secondary-button.danger { + border-color: #4b2529; + background: #2b1316; + color: #fca5a5; +} + +.secondary-button:disabled, +.primary:disabled, +.integration-toggle:disabled, +.secret-inline-action:disabled { + cursor: default; + opacity: 0.7; +} + +.detail-section { + margin-bottom: 18px; +} + +.detail-section h3 { + margin-bottom: 18px; + color: var(--text-tertiary); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.detail-edit-section { + max-width: 420px; +} + +.detail-inline-value { + color: var(--text-primary); + font-size: 16px; + font-weight: 600; +} + +.detail-section.compact { + margin-bottom: 0; +} + +.detail-fields, +.device-list { + display: grid; + gap: 16px; +} + +.detail-fields { + grid-template-columns: repeat(2, minmax(220px, 1fr)); +} + +.detail-field { + display: flex; + flex-direction: column; + gap: 8px; + padding: 16px 18px; + border: 1px solid var(--border-subtle); + border-radius: 16px; + background: var(--bg-panel); +} + +.detail-label, +.secret-type { + color: var(--text-tertiary); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.detail-value { + color: var(--text-primary); + font-size: 15px; + font-weight: 600; + word-break: break-word; +} + +.metadata-editor { + display: grid; + gap: 10px; + margin-bottom: 12px; +} + +.metadata-row { + display: grid; + grid-template-columns: minmax(140px, 180px) minmax(0, 1fr) auto; + gap: 10px; +} + +.metadata-row .detail-input { + min-width: 0; +} + +.secret-list { + display: grid; + gap: 18px; +} + +.secret-card, +.device-card { + padding: 20px 22px; + border: 1px solid var(--border-subtle); + border-radius: 20px; + background: var(--bg-elevated); +} + +.secret-row { + display: flex; + align-items: center; + gap: 14px; + margin-top: 12px; +} + +.secret-shell { + flex: 1; + display: flex; + align-items: center; + gap: 10px; + min-width: 0; + padding: 14px 16px; + border: 1px solid var(--border-subtle); + border-radius: 14px; + background: var(--bg-input); +} + +.secret-leading-icon, +.secret-inline-action { + color: var(--text-secondary); + font-size: 14px; +} + +.secret-inline-action { + border: none; + background: transparent; +} + +.secret-value { + flex: 1; + min-width: 0; + color: var(--text-secondary); + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 13px; + letter-spacing: 0.04em; + text-align: left; +} + +.secret-copy-button { + flex: none; +} + +.secret-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.action-button { + min-height: 40px; + padding-inline: 16px; +} + +.action-button .button-label { + color: inherit; +} + +.modal { + position: fixed; + inset: 0; + display: grid; + place-items: center; + padding: 24px; + background: rgba(5, 5, 7, 0.72); +} + +.modal-card { + width: min(560px, 100%); + max-height: calc(100vh - 48px); + overflow: auto; + padding: 28px; + background: var(--bg-panel); + border: 1px solid var(--border-subtle); + border-radius: 24px; + box-shadow: var(--shadow-modal); +} + +.modal-card.wide { + width: min(960px, 100%); +} + +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 10px; +} + +.modal-header h3 { + font-size: 22px; + font-weight: 700; +} + +.icon-button { + width: 32px; + height: 32px; + border: 1px solid var(--border-strong); + border-radius: 999px; + background: var(--bg-elevated); + color: var(--text-primary); + cursor: pointer; +} + +.modal-copy { + margin: 0 0 20px; + color: var(--text-secondary); + font-size: 13px; + line-height: 1.45; +} + +.modal-section { + margin-bottom: 20px; +} + +.modal-footnote { + margin: 14px 0 0; + color: var(--text-tertiary); + font-size: 11px; + line-height: 1.45; +} + +.modal-form { + display: grid; + gap: 14px; +} + +.field-label { + display: grid; + gap: 8px; + color: var(--text-secondary); + font-size: 13px; +} + +.detail-textarea { + min-height: 128px; + resize: vertical; +} + +.modal-actions { + display: flex; + justify-content: flex-end; + gap: 10px; + margin-top: 22px; +} + +.section-header-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 14px; +} + +.history-list { + display: grid; + gap: 14px; +} + +.history-item { + padding: 18px; + border: 1px solid var(--border-subtle); + border-radius: 18px; + background: var(--bg-elevated); +} + +.history-topline, +.history-meta, +.toolbar { + display: flex; + align-items: center; + gap: 10px; +} + +.history-topline, +.toolbar { + justify-content: space-between; +} + +.history-meta { + margin-top: 10px; + flex-wrap: wrap; + color: var(--text-tertiary); + font-size: 12px; +} + +.history-value { + margin-top: 14px; + padding: 14px 16px; + border: 1px solid var(--border-subtle); + border-radius: 14px; + background: var(--bg-input); + color: var(--text-secondary); + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 12px; + line-height: 1.5; + white-space: pre-wrap; + word-break: break-word; +} + +.history-actions { + margin-top: 14px; + display: flex; + justify-content: flex-end; +} + +.integration-list { + display: grid; + gap: 0; + border: 1px solid var(--border-subtle); + border-radius: 18px; + overflow: hidden; + background: var(--bg-panel); +} + +.integration-row-card { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 14px 20px; + border-bottom: 1px solid var(--border-subtle); +} + +.integration-row-card:last-child { + border-bottom: none; +} + +.integration-app { + display: flex; + align-items: center; + gap: 12px; + min-width: 0; +} + +.integration-icon { + width: 34px; + height: 34px; + display: grid; + place-items: center; + border-radius: 10px; + background: var(--bg-elevated); + color: var(--text-secondary); + font-size: 13px; +} + +.integration-path { + margin-top: 4px; + font-size: 12px; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + word-break: break-all; +} + +.integration-toggle { + display: inline-flex; + align-items: center; + justify-content: flex-end; + width: 40px; + height: 22px; + padding: 3px; + border: 1px solid transparent; + border-radius: 999px; + background: var(--bg-hover); + cursor: pointer; +} + +.integration-toggle.is-on { + background: var(--accent-blue); + border-color: var(--accent-blue-hover); +} + +.integration-toggle-knob { + width: 16px; + height: 16px; + border-radius: 999px; + background: var(--bg-panel); + border: 1px solid var(--bg-app); +} + +.integration-toggle:not(.is-on) .integration-toggle-knob { + margin-right: auto; +} + +.integration-toggle.is-loading { + opacity: 0.7; +} + +.mcp-json-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 16px; +} + +.mcp-json-header h4 { + font-size: 16px; + font-weight: 600; +} + +.mcp-config { + margin: 0; + padding: 18px; + border: 1px solid var(--border-subtle); + border-radius: 16px; + background: var(--bg-input); + color: #cbd5e1; + overflow: auto; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 12px; + line-height: 1.5; +} + +.button-icon { + font-size: 13px; + color: var(--text-secondary); + line-height: 1; +} + +.primary .button-icon { + color: var(--text-primary); +} + +.device-list { + gap: 12px; +} + +.empty-card { + padding: 18px; + border: 1px solid var(--border-subtle); + border-radius: 16px; + background: var(--bg-panel); +} + +.empty-title { + color: var(--text-primary); + font-size: 14px; + font-weight: 600; +} + +.empty-copy { + color: var(--text-secondary); + font-size: 13px; + line-height: 1.45; +} + +@media (max-width: 1180px) { + .main-shell { + grid-template-columns: 340px minmax(0, 1fr); + } + + .detail-fields { + grid-template-columns: minmax(0, 1fr); + } +} + +@media (max-width: 960px) { + .shell { + grid-template-columns: 220px minmax(0, 1fr); + } + + .main-shell { + grid-template-columns: minmax(280px, 320px) minmax(0, 1fr); + } + + .detail-pane { + padding: 28px 20px; + } +}