From a3da56ee32550f255cd3c1b34787cf4e2dbb6207 Mon Sep 17 00:00:00 2001 From: AKHIL Date: Sat, 15 Aug 2026 00:04:14 +0530 Subject: [PATCH 01/11] Fix Markdown download notification --- apps/desktop-ui/messages/af.json | 3 +++ apps/desktop-ui/messages/ar.json | 3 +++ apps/desktop-ui/messages/ca.json | 3 +++ apps/desktop-ui/messages/cs.json | 3 +++ apps/desktop-ui/messages/da.json | 3 +++ apps/desktop-ui/messages/de.json | 3 +++ apps/desktop-ui/messages/el.json | 3 +++ apps/desktop-ui/messages/en.json | 3 +++ apps/desktop-ui/messages/es.json | 3 +++ apps/desktop-ui/messages/fa.json | 3 +++ apps/desktop-ui/messages/fr.json | 3 +++ apps/desktop-ui/messages/id.json | 3 +++ apps/desktop-ui/messages/it.json | 3 +++ apps/desktop-ui/messages/ja.json | 3 +++ apps/desktop-ui/messages/ko.json | 3 +++ apps/desktop-ui/messages/ms.json | 3 +++ apps/desktop-ui/messages/nb.json | 3 +++ apps/desktop-ui/messages/nl.json | 3 +++ apps/desktop-ui/messages/pl.json | 3 +++ apps/desktop-ui/messages/pt-BR.json | 3 +++ apps/desktop-ui/messages/pt.json | 3 +++ apps/desktop-ui/messages/ru.json | 3 +++ apps/desktop-ui/messages/sv.json | 3 +++ apps/desktop-ui/messages/tr.json | 3 +++ apps/desktop-ui/messages/uk.json | 3 +++ apps/desktop-ui/messages/vi.json | 3 +++ apps/desktop-ui/messages/zh.json | 3 +++ .../markdown-preview-html-layout.tsx | 22 +++++++++++++--- .../__tests__/markdown-preview-html.test.ts | 25 +++++++++++++++++++ .../src/lib/markdown-preview-html.ts | 10 ++++++++ 30 files changed, 135 insertions(+), 3 deletions(-) create mode 100644 apps/desktop-ui/src/lib/__tests__/markdown-preview-html.test.ts create mode 100644 apps/desktop-ui/src/lib/markdown-preview-html.ts diff --git a/apps/desktop-ui/messages/af.json b/apps/desktop-ui/messages/af.json index 5a337a72..d2892f82 100644 --- a/apps/desktop-ui/messages/af.json +++ b/apps/desktop-ui/messages/af.json @@ -3912,6 +3912,9 @@ "copyHtml": "Kopieer HTML", "copyMarkdown": "Kopieer Markdown", "exportHtml": "Voer HTML uit", + "fileNameLabel": "Lêernaam", + "fileNamePlaceholder": "export", + "exportSuccess": "{name} gestoor", "copied": "Gekopieer", "charCount": "{count, plural, one {# karakter} other {# karakters}}", "emptyHint": "Begin Markdown tik om die lewendige voorskou en HTML-uitvoer te sien.", diff --git a/apps/desktop-ui/messages/ar.json b/apps/desktop-ui/messages/ar.json index af916e22..0c39d973 100644 --- a/apps/desktop-ui/messages/ar.json +++ b/apps/desktop-ui/messages/ar.json @@ -3901,6 +3901,9 @@ "copyHtml": "نسخ HTML", "copyMarkdown": "نسخ Markdown", "exportHtml": "تصدير HTML", + "fileNameLabel": "اسم الملف", + "fileNamePlaceholder": "export", + "exportSuccess": "تم حفظ {name}", "copied": "تم النسخ", "charCount": "{count, plural, one {# حرف} other {# أحرف}}", "emptyHint": "ابدأ بكتابة Markdown لرؤية المعاينة المباشرة وإخراج HTML.", diff --git a/apps/desktop-ui/messages/ca.json b/apps/desktop-ui/messages/ca.json index 3e2d13b7..b7e93eb6 100644 --- a/apps/desktop-ui/messages/ca.json +++ b/apps/desktop-ui/messages/ca.json @@ -3901,6 +3901,9 @@ "copyHtml": "Copia HTML", "copyMarkdown": "Copia Markdown", "exportHtml": "Exporta HTML", + "fileNameLabel": "Nom del fitxer", + "fileNamePlaceholder": "export", + "exportSuccess": "S'ha desat {name}", "copied": "Copiat", "charCount": "{count, plural, one {# caràcter} other {# caràcters}}", "emptyHint": "Comenceu a escriure Markdown per veure la previsualització i la sortida HTML.", diff --git a/apps/desktop-ui/messages/cs.json b/apps/desktop-ui/messages/cs.json index 1d9021db..ac626ac9 100644 --- a/apps/desktop-ui/messages/cs.json +++ b/apps/desktop-ui/messages/cs.json @@ -3901,6 +3901,9 @@ "copyHtml": "Kopírovat HTML", "copyMarkdown": "Kopírovat Markdown", "exportHtml": "Exportovat HTML", + "fileNameLabel": "Název souboru", + "fileNamePlaceholder": "export", + "exportSuccess": "Uloženo {name}", "copied": "Zkopírováno", "charCount": "{count, plural, one {# znak} few {# znaky} other {# znaků}}", "emptyHint": "Začněte psát Markdown pro zobrazení živého náhledu a HTML výstupu.", diff --git a/apps/desktop-ui/messages/da.json b/apps/desktop-ui/messages/da.json index 384a8985..5a1f8fb1 100644 --- a/apps/desktop-ui/messages/da.json +++ b/apps/desktop-ui/messages/da.json @@ -3901,6 +3901,9 @@ "copyHtml": "Kopiér HTML", "copyMarkdown": "Kopiér Markdown", "exportHtml": "Eksportér HTML", + "fileNameLabel": "Filnavn", + "fileNamePlaceholder": "export", + "exportSuccess": "Gemte {name}", "copied": "Kopieret", "charCount": "{count, plural, one {# tegn} other {# tegn}}", "emptyHint": "Begynd at skrive Markdown for at se live-forhåndsvisning og HTML-output.", diff --git a/apps/desktop-ui/messages/de.json b/apps/desktop-ui/messages/de.json index 904cf6cb..b5369cb6 100644 --- a/apps/desktop-ui/messages/de.json +++ b/apps/desktop-ui/messages/de.json @@ -3901,6 +3901,9 @@ "copyHtml": "HTML kopieren", "copyMarkdown": "Markdown kopieren", "exportHtml": "HTML exportieren", + "fileNameLabel": "Dateiname", + "fileNamePlaceholder": "export", + "exportSuccess": "{name} gespeichert", "copied": "Kopiert", "charCount": "{count, plural, one {# Zeichen} other {# Zeichen}}", "emptyHint": "Beginnen Sie mit der Eingabe von Markdown, um die Live-Vorschau und HTML-Ausgabe zu sehen.", diff --git a/apps/desktop-ui/messages/el.json b/apps/desktop-ui/messages/el.json index 3f083b32..c461a502 100644 --- a/apps/desktop-ui/messages/el.json +++ b/apps/desktop-ui/messages/el.json @@ -3901,6 +3901,9 @@ "copyHtml": "Αντιγραφή HTML", "copyMarkdown": "Αντιγραφή Markdown", "exportHtml": "Εξαγωγή HTML", + "fileNameLabel": "Όνομα αρχείου", + "fileNamePlaceholder": "export", + "exportSuccess": "Αποθηκεύτηκε το {name}", "copied": "Αντιγράφηκε", "charCount": "{count, plural, one {# χαρακτήρας} other {# χαρακτήρες}}", "emptyHint": "Ξεκινήστε να πληκτρολογείτε Markdown για να δείτε την προεπισκόπηση και την έξοδο HTML.", diff --git a/apps/desktop-ui/messages/en.json b/apps/desktop-ui/messages/en.json index b7172781..74be4c4f 100644 --- a/apps/desktop-ui/messages/en.json +++ b/apps/desktop-ui/messages/en.json @@ -3955,6 +3955,9 @@ "copyHtml": "Copy HTML", "copyMarkdown": "Copy Markdown", "exportHtml": "Export HTML", + "fileNameLabel": "File name", + "fileNamePlaceholder": "export", + "exportSuccess": "Saved {name}", "copied": "Copied", "charCount": "{count, plural, one {# character} other {# characters}}", "emptyHint": "Start typing Markdown to see the live preview and HTML output.", diff --git a/apps/desktop-ui/messages/es.json b/apps/desktop-ui/messages/es.json index e375ca3d..1d648af4 100644 --- a/apps/desktop-ui/messages/es.json +++ b/apps/desktop-ui/messages/es.json @@ -3912,6 +3912,9 @@ "copyHtml": "Copiar HTML", "copyMarkdown": "Copiar Markdown", "exportHtml": "Exportar HTML", + "fileNameLabel": "Nombre del archivo", + "fileNamePlaceholder": "export", + "exportSuccess": "Se guardó {name}", "copied": "Copiado", "charCount": "{count, plural, one {# carácter} other {# caracteres}}", "emptyHint": "Empieza a escribir Markdown para ver la vista previa y la salida HTML.", diff --git a/apps/desktop-ui/messages/fa.json b/apps/desktop-ui/messages/fa.json index b51051d3..ab5ea449 100644 --- a/apps/desktop-ui/messages/fa.json +++ b/apps/desktop-ui/messages/fa.json @@ -3912,6 +3912,9 @@ "copyHtml": "کپی HTML", "copyMarkdown": "کپی Markdown", "exportHtml": "خروجی HTML", + "fileNameLabel": "نام فایل", + "fileNamePlaceholder": "export", + "exportSuccess": "{name} ذخیره شد", "copied": "کپی شد", "charCount": "{count, plural, other {# کاراکتر}}", "emptyHint": "برای دیدن پیش‌نمایش زنده و خروجی HTML، Markdown تایپ کنید.", diff --git a/apps/desktop-ui/messages/fr.json b/apps/desktop-ui/messages/fr.json index d81dc1bd..3140255d 100644 --- a/apps/desktop-ui/messages/fr.json +++ b/apps/desktop-ui/messages/fr.json @@ -3912,6 +3912,9 @@ "copyHtml": "Copier le HTML", "copyMarkdown": "Copier le Markdown", "exportHtml": "Exporter le HTML", + "fileNameLabel": "Nom du fichier", + "fileNamePlaceholder": "export", + "exportSuccess": "{name} enregistré", "copied": "Copié", "charCount": "{count, plural, one {# caractère} other {# caractères}}", "emptyHint": "Commencez à taper du Markdown pour voir l'aperçu et la sortie HTML.", diff --git a/apps/desktop-ui/messages/id.json b/apps/desktop-ui/messages/id.json index e7bb6f71..038f002a 100644 --- a/apps/desktop-ui/messages/id.json +++ b/apps/desktop-ui/messages/id.json @@ -3901,6 +3901,9 @@ "copyHtml": "Salin HTML", "copyMarkdown": "Salin Markdown", "exportHtml": "Ekspor HTML", + "fileNameLabel": "Nama file", + "fileNamePlaceholder": "export", + "exportSuccess": "{name} disimpan", "copied": "Disalin", "charCount": "{count, plural, other {# karakter}}", "emptyHint": "Mulai mengetik Markdown untuk melihat pratinjau langsung dan output HTML.", diff --git a/apps/desktop-ui/messages/it.json b/apps/desktop-ui/messages/it.json index 628ee78c..d674c16e 100644 --- a/apps/desktop-ui/messages/it.json +++ b/apps/desktop-ui/messages/it.json @@ -3912,6 +3912,9 @@ "copyHtml": "Copia HTML", "copyMarkdown": "Copia Markdown", "exportHtml": "Esporta HTML", + "fileNameLabel": "Nome file", + "fileNamePlaceholder": "export", + "exportSuccess": "{name} salvato", "copied": "Copiato", "charCount": "{count, plural, one {# carattere} other {# caratteri}}", "emptyHint": "Inizia a digitare Markdown per vedere l'anteprima live e l'output HTML.", diff --git a/apps/desktop-ui/messages/ja.json b/apps/desktop-ui/messages/ja.json index 7e4e29dd..a113fd8a 100644 --- a/apps/desktop-ui/messages/ja.json +++ b/apps/desktop-ui/messages/ja.json @@ -3901,6 +3901,9 @@ "copyHtml": "HTMLをコピー", "copyMarkdown": "Markdownをコピー", "exportHtml": "HTMLをエクスポート", + "fileNameLabel": "ファイル名", + "fileNamePlaceholder": "export", + "exportSuccess": "{name} を保存しました", "copied": "コピーしました", "charCount": "{count, plural, other {# 文字}}", "emptyHint": "Markdownの入力を開始すると、ライブプレビューとHTML出力が表示されます。", diff --git a/apps/desktop-ui/messages/ko.json b/apps/desktop-ui/messages/ko.json index a457f19a..de039280 100644 --- a/apps/desktop-ui/messages/ko.json +++ b/apps/desktop-ui/messages/ko.json @@ -3901,6 +3901,9 @@ "copyHtml": "HTML 복사", "copyMarkdown": "Markdown 복사", "exportHtml": "HTML 내보내기", + "fileNameLabel": "파일 이름", + "fileNamePlaceholder": "export", + "exportSuccess": "{name} 저장됨", "copied": "복사됨", "charCount": "{count, plural, other {# 자}}", "emptyHint": "Markdown을 입력하면 실시간 미리보기와 HTML 출력을 볼 수 있습니다.", diff --git a/apps/desktop-ui/messages/ms.json b/apps/desktop-ui/messages/ms.json index c1fbc121..b67e2c33 100644 --- a/apps/desktop-ui/messages/ms.json +++ b/apps/desktop-ui/messages/ms.json @@ -3912,6 +3912,9 @@ "copyHtml": "Salin HTML", "copyMarkdown": "Salin Markdown", "exportHtml": "Eksport HTML", + "fileNameLabel": "Nama fail", + "fileNamePlaceholder": "export", + "exportSuccess": "{name} disimpan", "copied": "Disalin", "charCount": "{count, plural, other {# aksara}}", "emptyHint": "Mulakan menaip Markdown untuk melihat pratonton langsung dan output HTML.", diff --git a/apps/desktop-ui/messages/nb.json b/apps/desktop-ui/messages/nb.json index 0bd15dcf..0c821e07 100644 --- a/apps/desktop-ui/messages/nb.json +++ b/apps/desktop-ui/messages/nb.json @@ -3901,6 +3901,9 @@ "copyHtml": "Kopier HTML", "copyMarkdown": "Kopier Markdown", "exportHtml": "Eksporter HTML", + "fileNameLabel": "Filnavn", + "fileNamePlaceholder": "export", + "exportSuccess": "Lagret {name}", "copied": "Kopiert", "charCount": "{count, plural, one {# tegn} other {# tegn}}", "emptyHint": "Begynn å skrive Markdown for å se live-forhåndsvisning og HTML-utdata.", diff --git a/apps/desktop-ui/messages/nl.json b/apps/desktop-ui/messages/nl.json index 9f40ae87..924bf747 100644 --- a/apps/desktop-ui/messages/nl.json +++ b/apps/desktop-ui/messages/nl.json @@ -3901,6 +3901,9 @@ "copyHtml": "HTML kopiëren", "copyMarkdown": "Markdown kopiëren", "exportHtml": "HTML exporteren", + "fileNameLabel": "Bestandsnaam", + "fileNamePlaceholder": "export", + "exportSuccess": "{name} opgeslagen", "copied": "Gekopieerd", "charCount": "{count, plural, one {# teken} other {# tekens}}", "emptyHint": "Begin met het typen van Markdown om de live preview en HTML-uitvoer te zien.", diff --git a/apps/desktop-ui/messages/pl.json b/apps/desktop-ui/messages/pl.json index 7d290e31..608db375 100644 --- a/apps/desktop-ui/messages/pl.json +++ b/apps/desktop-ui/messages/pl.json @@ -3901,6 +3901,9 @@ "copyHtml": "Kopiuj HTML", "copyMarkdown": "Kopiuj Markdown", "exportHtml": "Eksportuj HTML", + "fileNameLabel": "Nazwa pliku", + "fileNamePlaceholder": "export", + "exportSuccess": "Zapisano {name}", "copied": "Skopiowano", "charCount": "{count, plural, one {# znak} few {# znaki} other {# znaków}}", "emptyHint": "Zacznij wpisywać Markdown, aby zobaczyć podgląd na żywo i wyjście HTML.", diff --git a/apps/desktop-ui/messages/pt-BR.json b/apps/desktop-ui/messages/pt-BR.json index 41b8a1bf..b83ce893 100644 --- a/apps/desktop-ui/messages/pt-BR.json +++ b/apps/desktop-ui/messages/pt-BR.json @@ -3901,6 +3901,9 @@ "copyHtml": "Copiar HTML", "copyMarkdown": "Copiar Markdown", "exportHtml": "Exportar HTML", + "fileNameLabel": "Nome do arquivo", + "fileNamePlaceholder": "export", + "exportSuccess": "{name} salvo", "copied": "Copiado", "charCount": "{count, plural, one {# caractere} other {# caracteres}}", "emptyHint": "Comece a digitar Markdown para ver a pré-visualização ao vivo e a saída HTML.", diff --git a/apps/desktop-ui/messages/pt.json b/apps/desktop-ui/messages/pt.json index d54101f6..c1209e4a 100644 --- a/apps/desktop-ui/messages/pt.json +++ b/apps/desktop-ui/messages/pt.json @@ -3901,6 +3901,9 @@ "copyHtml": "Copiar HTML", "copyMarkdown": "Copiar Markdown", "exportHtml": "Exportar HTML", + "fileNameLabel": "Nome do ficheiro", + "fileNamePlaceholder": "export", + "exportSuccess": "{name} guardado", "copied": "Copiado", "charCount": "{count, plural, one {# carácter} other {# caracteres}}", "emptyHint": "Comece a escrever Markdown para ver a pré-visualização em tempo real e a saída HTML.", diff --git a/apps/desktop-ui/messages/ru.json b/apps/desktop-ui/messages/ru.json index 75468f61..8f210ec8 100644 --- a/apps/desktop-ui/messages/ru.json +++ b/apps/desktop-ui/messages/ru.json @@ -3901,6 +3901,9 @@ "copyHtml": "Копировать HTML", "copyMarkdown": "Копировать Markdown", "exportHtml": "Экспорт HTML", + "fileNameLabel": "Имя файла", + "fileNamePlaceholder": "export", + "exportSuccess": "Сохранено: {name}", "copied": "Скопировано", "charCount": "{count, plural, one {# символ} few {# символа} other {# символов}}", "emptyHint": "Начните вводить Markdown, чтобы увидеть предпросмотр и HTML вывод.", diff --git a/apps/desktop-ui/messages/sv.json b/apps/desktop-ui/messages/sv.json index 84349c34..3e5f9d84 100644 --- a/apps/desktop-ui/messages/sv.json +++ b/apps/desktop-ui/messages/sv.json @@ -3912,6 +3912,9 @@ "copyHtml": "Kopiera HTML", "copyMarkdown": "Kopiera Markdown", "exportHtml": "Exportera HTML", + "fileNameLabel": "Filnamn", + "fileNamePlaceholder": "export", + "exportSuccess": "Sparade {name}", "copied": "Kopierat", "charCount": "{count, plural, one {# tecken} other {# tecken}}", "emptyHint": "Börja skriva Markdown för att se live-förhandsgranskning och HTML-utdata.", diff --git a/apps/desktop-ui/messages/tr.json b/apps/desktop-ui/messages/tr.json index 7eb13661..fb313431 100644 --- a/apps/desktop-ui/messages/tr.json +++ b/apps/desktop-ui/messages/tr.json @@ -3912,6 +3912,9 @@ "copyHtml": "HTML'yi Kopyala", "copyMarkdown": "Markdown'ı Kopyala", "exportHtml": "HTML'yi Dışa Aktar", + "fileNameLabel": "Dosya adı", + "fileNamePlaceholder": "export", + "exportSuccess": "{name} kaydedildi", "copied": "Kopyalandı", "charCount": "{count, plural, one {# karakter} other {# karakter}}", "emptyHint": "Canlı önizleme ve HTML çıktısını görmek için Markdown yazmaya başlayın.", diff --git a/apps/desktop-ui/messages/uk.json b/apps/desktop-ui/messages/uk.json index 39a67e43..7930c106 100644 --- a/apps/desktop-ui/messages/uk.json +++ b/apps/desktop-ui/messages/uk.json @@ -3912,6 +3912,9 @@ "copyHtml": "Копіювати HTML", "copyMarkdown": "Копіювати Markdown", "exportHtml": "Експортувати HTML", + "fileNameLabel": "Назва файлу", + "fileNamePlaceholder": "export", + "exportSuccess": "Збережено: {name}", "copied": "Скопійовано", "charCount": "{count, plural, one {# символ} few {# символи} other {# символів}}", "emptyHint": "Почніть вводити Markdown, щоб побачити попередній перегляд і HTML вивід.", diff --git a/apps/desktop-ui/messages/vi.json b/apps/desktop-ui/messages/vi.json index 225a33db..0c1a6c24 100644 --- a/apps/desktop-ui/messages/vi.json +++ b/apps/desktop-ui/messages/vi.json @@ -3901,6 +3901,9 @@ "copyHtml": "Sao chép HTML", "copyMarkdown": "Sao chép Markdown", "exportHtml": "Xuất HTML", + "fileNameLabel": "Tên tệp", + "fileNamePlaceholder": "export", + "exportSuccess": "Đã lưu {name}", "copied": "Đã sao chép", "charCount": "{count, plural, other {# ký tự}}", "emptyHint": "Bắt đầu nhập Markdown để xem bản xem trước trực tiếp và đầu ra HTML.", diff --git a/apps/desktop-ui/messages/zh.json b/apps/desktop-ui/messages/zh.json index aea85c0c..292ee13e 100644 --- a/apps/desktop-ui/messages/zh.json +++ b/apps/desktop-ui/messages/zh.json @@ -3901,6 +3901,9 @@ "copyHtml": "复制 HTML", "copyMarkdown": "复制 Markdown", "exportHtml": "导出 HTML", + "fileNameLabel": "文件名", + "fileNamePlaceholder": "export", + "exportSuccess": "已保存 {name}", "copied": "已复制", "charCount": "{count, plural, other {# 个字符}}", "emptyHint": "开始输入 Markdown 以查看实时预览和 HTML 输出。", diff --git a/apps/desktop-ui/src/components/markdown-preview-html/markdown-preview-html-layout.tsx b/apps/desktop-ui/src/components/markdown-preview-html/markdown-preview-html-layout.tsx index 92639879..f268f3c6 100644 --- a/apps/desktop-ui/src/components/markdown-preview-html/markdown-preview-html-layout.tsx +++ b/apps/desktop-ui/src/components/markdown-preview-html/markdown-preview-html-layout.tsx @@ -5,7 +5,10 @@ import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'; import { useTranslations } from 'next-intl'; import { marked } from 'marked'; import TurndownService from 'turndown'; +import { toast } from 'sonner'; +import { safeFileName } from '@/lib/markdown-preview-html'; import { Card } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Textarea } from '@/components/ui/textarea'; import { Button } from '@/components/ui/button'; @@ -76,6 +79,7 @@ export function MarkdownPreviewLayout() { // Markdown → HTML tab const [markdown, setMarkdown] = useState(''); const [outputTab, setOutputTab] = useState<'preview' | 'html'>('preview'); + const [fileName, setFileName] = useState('export'); const { isCopied: copiedHtml, copyToClipboard: copyHtml } = useCopyToClipboard(); // HTML → Markdown tab @@ -92,14 +96,16 @@ export function MarkdownPreviewLayout() { const handleExportHtml = useCallback(() => { if (!renderedHtml) return; + const name = `${safeFileName(fileName)}.html`; const blob = new Blob([buildFullHtml(renderedHtml)], { type: 'text/html' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; - a.download = 'export.html'; + a.download = name; a.click(); URL.revokeObjectURL(url); - }, [renderedHtml]); + toast.success(t('exportSuccess', { name })); + }, [renderedHtml, fileName, t]); const handleCopyMd = useCallback(() => { if (!convertedMarkdown) return; @@ -153,7 +159,17 @@ export function MarkdownPreviewLayout() { -
+
+
+ setFileName(e.target.value)} + aria-label={t('fileNameLabel')} + placeholder={t('fileNamePlaceholder')} + className="h-8 w-28 border-0 bg-transparent px-2 text-xs shadow-none focus-visible:ring-0" + /> + .html +
+ } + sidebar={sidebar} + >
- {/* Narrow viewports keep the sidebar in the Sheet; without this - trigger there is no way to add a connection or open a tab. */} - {!isWide && ( -
- -
- )} void reloadConnections()} /> -
+ ); } diff --git a/apps/desktop-ui/src/app/app/environment-manager/page.tsx b/apps/desktop-ui/src/app/app/environment-manager/page.tsx index 450d7fa4..3d8a95ad 100644 --- a/apps/desktop-ui/src/app/app/environment-manager/page.tsx +++ b/apps/desktop-ui/src/app/app/environment-manager/page.tsx @@ -3,7 +3,6 @@ import { useEffect, useRef } from "react" import { AddEnvironmentSetDialog } from "@/components/environment-manager/add-environment-set-dialog" import { EnvironmentSetList } from "@/components/environment-manager/environment-set-list" -import { ToolHeader } from "@/components/tools/tool-header" import { useEnvironmentManagerStore, type EnvSetEntry } from "@/store/environment-manager-store" import { useVaultGuard } from "@/hooks/use-vault-guard" import { VaultLockedPlaceholder } from "@/components/vault-locked-placeholder" @@ -110,22 +109,11 @@ export default function EnvironmentManagerPage() { if (!user) return null + // Tool identity lives in the ToolSidebarLayout header EnvironmentSetList + // renders — no separate page header row. return ( -
- {!isMobile && ( - - )} - -
- -
- +
+ {isMobile && }
) diff --git a/apps/desktop-ui/src/app/app/layout.tsx b/apps/desktop-ui/src/app/app/layout.tsx index 6d19ddd7..12b60cbd 100644 --- a/apps/desktop-ui/src/app/app/layout.tsx +++ b/apps/desktop-ui/src/app/app/layout.tsx @@ -1,26 +1,13 @@ import { Suspense } from "react"; -import { cookies } from "next/headers"; -import { SidebarProvider } from "@/components/ui/sidebar"; import { AppContent } from "./app-content"; -// Static export (Tauri desktop) cannot use request APIs; fall back to defaults there. -const isTauriBuild = process.env.TAURI_BUILD === "1"; - -export default async function Layout({ children }: { children: React.ReactNode }) { - let defaultOpen = true; - if (!isTauriBuild) { - const cookieStore = await cookies(); - defaultOpen = cookieStore.get("sidebar:state")?.value !== "false"; - } - +export default function Layout({ children }: { children: React.ReactNode }) { return ( - - {/* Suspense boundary so tool pages that read useSearchParams() (base64, - jwt-decoder, hash-generator, url-encode, json-formatter, qr-code) pass - the static export prerender instead of erroring on a CSR bailout. */} - - {children} - - + // Suspense boundary so tool pages that read useSearchParams() (base64, + // jwt-decoder, hash-generator, url-encode, json-formatter, qr-code) pass + // the static export prerender instead of erroring on a CSR bailout. + + {children} + ); } diff --git a/apps/desktop-ui/src/app/app/notes/context/NotesContext.tsx b/apps/desktop-ui/src/app/app/notes/context/NotesContext.tsx index c80c09ec..b9add677 100644 --- a/apps/desktop-ui/src/app/app/notes/context/NotesContext.tsx +++ b/apps/desktop-ui/src/app/app/notes/context/NotesContext.tsx @@ -37,8 +37,6 @@ interface NotesUI { setActiveNoteId: (id: string | null) => void; focusMode: boolean; setFocusMode: (v: boolean) => void; - sidebarOpen: boolean; - setSidebarOpen: (v: boolean) => void; } interface NotesActions { @@ -65,7 +63,6 @@ export function NotesProvider({ children }: { children: React.ReactNode }) { const [isContentLoading, setIsContentLoading] = useState(false); const [activeNoteId, setActiveNoteId] = useState(null); const [focusMode, setFocusMode] = useState(false); - const [sidebarOpen, setSidebarOpen] = useState(true); // Decrypted bodies, tagged with the key they were decrypted under: a vault // lock/unlock invalidates the whole map on read, with no reset effect. const [contentStore, setContentStore] = useState({ key: null, map: EMPTY_CONTENT }); @@ -402,8 +399,8 @@ export function NotesProvider({ children }: { children: React.ReactNode }) { ); const uiValue = useMemo( - () => ({ activeNoteId, setActiveNoteId, focusMode, setFocusMode, sidebarOpen, setSidebarOpen }), - [activeNoteId, focusMode, sidebarOpen] + () => ({ activeNoteId, setActiveNoteId, focusMode, setFocusMode }), + [activeNoteId, focusMode] ); const actionsValue = useMemo( diff --git a/apps/desktop-ui/src/app/app/notes/notes-client-layout.tsx b/apps/desktop-ui/src/app/app/notes/notes-client-layout.tsx index 615ce2c7..94cc182a 100644 --- a/apps/desktop-ui/src/app/app/notes/notes-client-layout.tsx +++ b/apps/desktop-ui/src/app/app/notes/notes-client-layout.tsx @@ -2,69 +2,33 @@ import { NotesProvider, useNotesUI } from "./context/NotesContext"; import NotesSidebar from "@/components/notes/NotesSidebar"; -import { useMediaQuery } from "@/hooks/use-media-query"; -import { Sheet, SheetContent, SheetTrigger, SheetHeader, SheetTitle } from "@/components/ui/sheet"; -import { Menu, PanelLeftOpen, NotebookPen } from "lucide-react"; -import { Button } from "@/components/ui/button"; +import { ToolSidebarLayout } from "@/components/tools/tool-sidebar"; +import { NotebookPen } from "lucide-react"; import { useTranslations } from "next-intl"; function NotesLayout({ children }: { children: React.ReactNode }) { - const t = useTranslations("Notes.layout"); const tSidebar = useTranslations("Notes.sidebar"); - const isDesktop = useMediaQuery("(min-width: 768px)"); - const { focusMode, sidebarOpen, setSidebarOpen } = useNotesUI(); - const sidebarCollapsed = isDesktop && !focusMode && !sidebarOpen; + const { focusMode } = useNotesUI(); - return ( -
- {isDesktop && !focusMode && sidebarOpen && } - -
- {sidebarCollapsed && ( -
- -
- - - - {tSidebar("title")} -
-
- )} - {!isDesktop && !focusMode && ( -
- - - - - - - {t("mobileNavTitle")} - - - - -
- )} + // Focus mode drops the panel entirely — no column, no re-open affordance. + if (focusMode) { + return ( +
{children}
-
+ ); + } + + return ( + } + className="bg-background mobile-nav-offset" + > + {children} + ); } diff --git a/apps/desktop-ui/src/app/app/password-manager/page.tsx b/apps/desktop-ui/src/app/app/password-manager/page.tsx index 7a34f0ff..fd29359a 100644 --- a/apps/desktop-ui/src/app/app/password-manager/page.tsx +++ b/apps/desktop-ui/src/app/app/password-manager/page.tsx @@ -20,9 +20,6 @@ import { fetchAllPages } from "@/lib/fetch-all-pages" import { EncryptedToolPlaceholder } from "@/components/encrypted-tool-placeholder" import { useActiveWorkspace } from "@/store/workspace-store" import { hasWorkspaceEncryption } from "@/lib/workspace-rbac" -import { IconShieldLock } from "@tabler/icons-react" -import { CATEGORY_ACCENT } from "@/components/dashboard/types" -import { cn } from "@/lib/utils" const PASSWORDS_PAGE_SIZE = 500 @@ -122,34 +119,11 @@ export default function PasswordManagerPage() { if (!user) return null + // Tool identity and the add action live in the ToolSidebarLayout header + // PasswordList renders — no separate page header row. return ( -
- {!isMobile && ( -
-
- - - -

{t("title")}

-
- -
- )} - +
- {isMobile && }
) diff --git a/apps/desktop-ui/src/app/app/s3-drive/page.tsx b/apps/desktop-ui/src/app/app/s3-drive/page.tsx index f41dca8f..1baab488 100644 --- a/apps/desktop-ui/src/app/app/s3-drive/page.tsx +++ b/apps/desktop-ui/src/app/app/s3-drive/page.tsx @@ -12,9 +12,9 @@ import { listConnections } from "@/lib/s3-drive-api" import { decryptData } from "@/lib/encryption" import { BucketSidebar } from "@/components/s3-drive/bucket-sidebar" import { FileBrowser } from "@/components/s3-drive/file-browser" +import { ToolSidebarLayout } from "@/components/tools/tool-sidebar" import { IconBucket, IconCloud, IconArrowRight } from "@tabler/icons-react" import { Skeleton } from "@/components/ui/skeleton" -import { cn } from "@/lib/utils" import { DesktopOnlineGate } from "@/components/desktop/desktop-online-gate" export default function S3DrivePage() { @@ -67,7 +67,7 @@ function S3DriveInner() { return (
{/* Sidebar skeleton */} -
+
@@ -120,11 +120,12 @@ function S3DriveInner() { const activeConn = connections.find((c) => c.id === activeConnectionId) ?? null return ( -
-
- -
- + } + >
{activeConn ? ( )}
-
+ ) } diff --git a/apps/desktop-ui/src/app/app/to-do/TaskContainer.tsx b/apps/desktop-ui/src/app/app/to-do/TaskContainer.tsx index 99be9f73..8f5f43ee 100644 --- a/apps/desktop-ui/src/app/app/to-do/TaskContainer.tsx +++ b/apps/desktop-ui/src/app/app/to-do/TaskContainer.tsx @@ -19,6 +19,11 @@ import { useTaskContext } from "@/app/app/to-do/context/TaskContext"; import { useWorkspaceMembers, memberLabel } from "@/app/app/to-do/hooks/useWorkspaceMembers"; import { useProjectContext } from "@/app/app/to-do/context/ProjectContext"; import { ListTodo, Circle, LayoutGrid, List, Search, X, Plus, Folder, Archive, ArchiveRestore, UserRound } from "lucide-react"; +import { + ToolSidebarFilterList, + ToolSidebarLayout, + type ToolSidebarFilterItem, +} from "@/components/tools/tool-sidebar"; import { Button } from "@/components/ui/button"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { STATUS_CONFIG } from "./config/constants"; @@ -35,7 +40,6 @@ import { DrawerClose, DrawerTrigger, } from "@/components/ui/drawer"; -import { SidebarTrigger } from "@/components/ui/sidebar"; import { Badge } from "@/components/ui/badge"; import { Select, @@ -200,7 +204,25 @@ export const TaskContainer = () => { return () => window.removeEventListener("keydown", handleKeyDown); }, [hasSearchFilter, isMobile]); + const projectFilters: ToolSidebarFilterItem[] = [ + { id: "all", label: tFilters("allProjects") }, + ...projects.map((project) => ({ id: project.id, label: project.name, dot: project.color })), + ]; + return ( + + } + >
{liveStatsMessage} @@ -212,8 +234,6 @@ export const TaskContainer = () => { style={{ paddingTop: "calc(env(safe-area-inset-top, 0px) + 0.5rem)" }} >
- -
{
-
- - - -
-

- {tPage("myTasksTitle")} -

-

- {tPage("statsLineDesktop", { total: allTaskStats.total, percent: completionRate })} -

-
-
+ {/* Tool name + icon live in the ToolSidebarLayout panel header. */} +

+ {tPage("statsLineDesktop", { total: allTaskStats.total, percent: completionRate })} +

{/* Enhanced Stats with Progress Bars - Hidden on mobile to save space */}
@@ -397,27 +408,7 @@ export const TaskContainer = () => { )}
- {/* Project Filter */} - + {/* Project filter lives in the ToolSidebarLayout panel. */} {/* Assignee Filter — only meaningful in a shared workspace */} {isShared && ( @@ -739,5 +730,6 @@ export const TaskContainer = () => { )}
+ ); }; diff --git a/apps/desktop-ui/src/app/app/to-do/utils/__tests__/statusOrder.test.ts b/apps/desktop-ui/src/app/app/to-do/utils/__tests__/statusOrder.test.ts new file mode 100644 index 00000000..83e81bec --- /dev/null +++ b/apps/desktop-ui/src/app/app/to-do/utils/__tests__/statusOrder.test.ts @@ -0,0 +1,51 @@ +import { insertOrder, needsRespread, ORDER_STEP } from "../statusOrder"; + +describe("insertOrder", () => { + it("starts at 0 in an empty column", () => { + expect(insertOrder([], 0)).toBe(0); + }); + + it("appends a step past the last card", () => { + expect(insertOrder([0, 1000], 2)).toBe(2000); + }); + + it("prepends a step before the first card", () => { + expect(insertOrder([0, 1000], 0)).toBe(-ORDER_STEP); + }); + + it("lands between two neighbours", () => { + expect(insertOrder([0, 1000], 1)).toBe(500); + }); + + it("always returns an integer — Rust reads statusOrder with as_i64", () => { + // An odd gap must not produce a fraction, or the row is skipped server-side. + expect(Number.isInteger(insertOrder([0, 1], 1))).toBe(true); + expect(Number.isInteger(insertOrder([0, 7], 1))).toBe(true); + }); + + it("clamps an out-of-range index instead of returning NaN", () => { + expect(insertOrder([0, 1000], 99)).toBe(2000); + expect(insertOrder([0, 1000], -5)).toBe(-ORDER_STEP); + }); + + it("keeps legacy 1/2/3 orders sortable without a migration", () => { + // Rows written before statusOrder meant "position" are tightly packed. + const order = insertOrder([1, 2, 3], 3); + expect(order).toBeGreaterThan(3); + }); +}); + +describe("needsRespread", () => { + it("is false when the insert found room", () => { + expect(needsRespread([0, 1000], 1, 500)).toBe(false); + }); + + it("is true when the gap between neighbours is exhausted", () => { + // No integer exists strictly between 5 and 6. + expect(needsRespread([5, 6], 1, insertOrder([5, 6], 1))).toBe(true); + }); + + it("is false at the head of a column, where there is always room", () => { + expect(needsRespread([0, 1000], 0, -ORDER_STEP)).toBe(false); + }); +}); diff --git a/apps/desktop-ui/src/app/app/to-do/utils/statusOrder.ts b/apps/desktop-ui/src/app/app/to-do/utils/statusOrder.ts new file mode 100644 index 00000000..c86f5df5 --- /dev/null +++ b/apps/desktop-ui/src/app/app/to-do/utils/statusOrder.ts @@ -0,0 +1,36 @@ +/** + * Position of a card within its board column. + * + * Cards are spaced `ORDER_STEP` apart so that dropping one between two neighbours is a + * single PATCH of the moved card instead of a renumber of the whole column. When the gap + * between two neighbours is used up, the column is respread — see `needsRespread`. + * + * Integers only: the Rust handler reads `statusOrder` with `as_i64` inside a `filter_map`, + * so a fractional value is silently skipped when it computes the next order on create. + */ +export const ORDER_STEP = 1000; + +/** + * The `statusOrder` for a card inserted at `index` of a column whose existing cards have + * `orders` (ascending, excluding the card being moved). + */ +export function insertOrder(orders: number[], index: number): number { + const at = Math.max(0, Math.min(index, orders.length)); + const prev = orders[at - 1]; + const next = orders[at]; + + if (prev === undefined && next === undefined) return 0; + if (prev === undefined) return next - ORDER_STEP; + if (next === undefined) return prev + ORDER_STEP; + return Math.floor((prev + next) / 2); +} + +/** + * True when `insertOrder` could not find room between the neighbours, so the column has to + * be respread at even intervals before the position is meaningful. + */ +export function needsRespread(orders: number[], index: number, order: number): boolean { + const at = Math.max(0, Math.min(index, orders.length)); + const prev = orders[at - 1]; + return prev !== undefined && order <= prev; +} diff --git a/apps/desktop-ui/src/components/api-client/api-client.tsx b/apps/desktop-ui/src/components/api-client/api-client.tsx index 3a62396a..943e9519 100644 --- a/apps/desktop-ui/src/components/api-client/api-client.tsx +++ b/apps/desktop-ui/src/components/api-client/api-client.tsx @@ -29,6 +29,7 @@ import { parseCurlCommand } from "@/utils/curl-parser" import { generateCode } from "./generate-code" import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard" import { CollectionsSidebar } from "./collections/collections-sidebar" +import { ToolSidebarLayout } from "@/components/tools/tool-sidebar" import dynamic from "next/dynamic" import { RequestMethod, @@ -44,13 +45,11 @@ import type { ScriptContext } from "./workers/scripts-runner.worker" import { useTranslations } from "next-intl" import { toast } from "sonner" import { useIsMobile } from "@/components/hooks/use-mobile" -import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet" import { Button } from "@/components/ui/button" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" -import { FolderOpen, PanelRight, MoreVertical, MoreHorizontal, Cookie, Download, Activity, Keyboard } from "lucide-react" +import { FolderOpen, MoreVertical, MoreHorizontal, Cookie, Download, Activity, Keyboard } from "lucide-react" import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, DropdownMenuSeparator } from "@/components/ui/dropdown-menu" import { IconCode, IconSettings } from "@tabler/icons-react" -import { cn } from "@/lib/utils" import { ensureHttpScheme } from "@/lib/url-normalize" import { encodeBasicCredentials } from "@/lib/basic-auth" import { ensureFreshToken } from "@/lib/oauth2" @@ -131,8 +130,6 @@ function ApiClientInner() { const sessionVarsRef = React.useRef>({}) const isMobile = useIsMobile() - const [collectionsOpen, setCollectionsOpen] = React.useState(false) - const [sidebarOpen, setSidebarOpen] = React.useState(true) const [mobilePanel, setMobilePanel] = React.useState<'request' | 'response'>('request') const [envMgrOpen, setEnvMgrOpen] = React.useState(false) const [codeGenOpen, setCodeGenOpen] = React.useState(false) @@ -1105,31 +1102,15 @@ function ApiClientInner() { ) return ( -
+ } + className="mobile-nav-offset" + >
- {/* Mobile Collections Button */} - {isMobile && ( - - - - - -
-
- { - handleLoadRequest(request) - setCollectionsOpen(false) - }} - /> -
- - - )}
{isMobile ? ( /* Mobile: secondary actions collapsed into dropdown */ @@ -1270,15 +1251,6 @@ function ApiClientInner() {
- )} {/* Dialogs rendered outside the conditional so state is preserved */} @@ -1462,22 +1434,7 @@ function ApiClientInner() {
- - {/* Desktop Collections Sidebar */} - {!isMobile && ( -
- setSidebarOpen(false)} - /> -
- )} -
+ ) } diff --git a/apps/desktop-ui/src/components/api-client/collections/collections-sidebar.tsx b/apps/desktop-ui/src/components/api-client/collections/collections-sidebar.tsx index 1e8c41b6..d4164ce7 100644 --- a/apps/desktop-ui/src/components/api-client/collections/collections-sidebar.tsx +++ b/apps/desktop-ui/src/components/api-client/collections/collections-sidebar.tsx @@ -6,7 +6,7 @@ import { Checkbox } from "@/components/ui/checkbox" import { ScrollArea } from "@/components/ui/scroll-area" import { Collection, CollectionRequest } from "../types" import { CollectionItem } from "./collection-item" -import { FolderPlus, Trash2, Pencil, MoreHorizontal, Search, X, FileDown, Play, Server, Link2, Globe, PanelRightClose } from "lucide-react" +import { FolderPlus, Trash2, Pencil, MoreHorizontal, Search, X, FileDown, Play, Server, Link2, Globe } from "lucide-react" import { buildShareUrl } from "@/lib/share-link" import { apiFetch } from "@/lib/desktop/api-fetch" import { toast } from "sonner" @@ -47,16 +47,23 @@ import { useHistoryState, useHistoryActions } from "../context/history-context" import { CollectionsSidebarSkeleton, HistoryListSkeleton } from "../skeletons" import { useDebouncedValue } from "@/lib/use-debounced-value" import { isDesktop } from "@/lib/desktop/is-desktop" +import { ToolSidebarActions, useToolSidebarPanel } from "@/components/tools/tool-sidebar" interface CollectionsSidebarProps { onLoadRequest: (request: CollectionRequest) => void - onCollapse?: () => void } -export function CollectionsSidebar({ - onLoadRequest, - onCollapse, -}: CollectionsSidebarProps) { +export function CollectionsSidebar({ onLoadRequest: loadRequest }: CollectionsSidebarProps) { + const panel = useToolSidebarPanel() + // On mobile the panel is a sheet over the request pane — loading a request + // has to dismiss it or the loaded request stays hidden behind the sheet. + const onLoadRequest = React.useCallback( + (request: CollectionRequest) => { + loadRequest(request) + if (panel?.isMobile) panel.close() + }, + [loadRequest, panel], + ) const { collections, isLoading } = useCollectionsState() const { addFolder: onAddFolder, deleteItem: onDelete, toggleFolder: onToggle, createCollection: onCreateCollection, renameCollection: onRenameCollection, renameFolder: onRenameFolder, patchFolder, deleteMultipleCollections: onDeleteMultiple } = useCollectionsActions() const { history, isLoading: isHistoryLoading } = useHistoryState() @@ -189,45 +196,34 @@ export function CollectionsSidebar({ } return ( -
+ // Header (icon, title, collapse) is owned by ToolSidebarLayout in + // components/api-client/api-client.tsx — this renders the body only. + <> + + {selectedCollections.size > 0 && ( + + )} + + +
-
-

{t("title")}

-
- {selectedCollections.size > 0 && ( - - )} - - {onCollapse && ( - - )} -
-
+ ) } diff --git a/apps/desktop-ui/src/components/api-key-vault/api-key-list.tsx b/apps/desktop-ui/src/components/api-key-vault/api-key-list.tsx index cf0a9f96..36a5f3b7 100644 --- a/apps/desktop-ui/src/components/api-key-vault/api-key-list.tsx +++ b/apps/desktop-ui/src/components/api-key-vault/api-key-list.tsx @@ -19,6 +19,11 @@ import { deleteApiKeyEntry } from "@/lib/api-key-vault-api" import { cn } from "@/lib/utils" import { formatDistanceToNow } from "date-fns" import { AddApiKeyDialog, EditApiKeyDialog } from "./add-api-key-dialog" +import { + ToolSidebarFilterList, + ToolSidebarLayout, + type ToolSidebarFilterItem, +} from "@/components/tools/tool-sidebar" const REVEAL_TIMEOUT_MS = 30_000 @@ -174,8 +179,35 @@ export function ApiKeyList() { } } + const envFilters: ToolSidebarFilterItem[] = [ + { id: "all", label: "All keys", count: entries.length }, + { id: "development", label: ENV_META.development.label, count: counts.development, dot: ENV_META.development.dot }, + { id: "staging", label: ENV_META.staging.label, count: counts.staging, dot: ENV_META.staging.dot }, + { id: "production", label: ENV_META.production.label, count: counts.production, dot: ENV_META.production.dot }, + ] + return ( -
+ + + + } + sidebar={ + setEnvFilter(id as "all" | ApiKeyEnv)} + heading="Environment" + /> + } + > +
@@ -187,17 +219,6 @@ export function ApiKeyList() { className="pl-9" />
- -
+
+ ); } diff --git a/apps/desktop-ui/src/components/environment-manager/environment-set-list.tsx b/apps/desktop-ui/src/components/environment-manager/environment-set-list.tsx index a789d908..8c02bf95 100644 --- a/apps/desktop-ui/src/components/environment-manager/environment-set-list.tsx +++ b/apps/desktop-ui/src/components/environment-manager/environment-set-list.tsx @@ -9,7 +9,12 @@ import { Input } from "@/components/ui/input" import { Button } from "@/components/ui/button" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Badge } from "@/components/ui/badge" -import { Search, Copy, Pencil, Trash2, FileCode2, Eye, ArrowUpDown, Check } from "lucide-react" +import { Search, Copy, Pencil, Trash2, FileCode2, Eye, ArrowUpDown, Check, Folders as IconFolders } from "lucide-react" +import { + ToolSidebarFilterList, + ToolSidebarLayout, + type ToolSidebarFilterItem, +} from "@/components/tools/tool-sidebar" import { toast } from "sonner" import { deleteEnvSetEntry } from "@/lib/environment-manager-api" import { formatDotEnv } from "@/lib/environment-manager-utils" @@ -42,7 +47,6 @@ import { AddEnvironmentSetDialog } from "./add-environment-set-dialog" import { formatDistanceToNow } from "date-fns" import { cn } from "@/lib/utils" import { useIsMobile } from "@/components/hooks/use-mobile" -import { SidebarTrigger } from "@/components/ui/sidebar" import { Dialog, DialogContent, @@ -77,6 +81,7 @@ export function EnvironmentSetList() { const { copyToClipboard } = useCopyToClipboard() const [search, setSearch] = useState("") const [tagFilter, setTagFilter] = useState("") + const [projectFilter, setProjectFilter] = useState("all") const [sortOrder, setSortOrder] = useState("updatedDesc") const [editing, setEditing] = useState(null) const [editOpen, setEditOpen] = useState(false) @@ -91,10 +96,23 @@ export function EnvironmentSetList() { return [...s].sort() }, [sets]) + // Project buckets for the sidebar, sorted by name with an "all" row on top. + const projectFilters: ToolSidebarFilterItem[] = useMemo(() => { + const counts = new Map() + sets.forEach((e) => counts.set(e.project, (counts.get(e.project) ?? 0) + 1)) + return [ + { id: "all", label: t("allProjects"), count: sets.length }, + ...[...counts.entries()] + .sort((a, b) => a[0].localeCompare(b[0])) + .map(([project, count]) => ({ id: project, label: project, count })), + ] + }, [sets, t]) + const filtered = useMemo(() => { const q = debouncedSearch.toLowerCase().trim() const tf = tagFilter.toLowerCase().trim() const result = sets.filter((e) => { + if (projectFilter !== "all" && e.project !== projectFilter) return false const matchTag = !tf || e.tags.some((x) => x.toLowerCase().includes(tf)) || tf === e.project.toLowerCase() if (!q) return matchTag @@ -107,7 +125,7 @@ export function EnvironmentSetList() { if (sortOrder === "updatedAsc") return a.updatedAt - b.updatedAt return `${a.project}/${a.environment}`.localeCompare(`${b.project}/${b.environment}`) }) - }, [sets, debouncedSearch, tagFilter, sortOrder]) + }, [sets, debouncedSearch, tagFilter, projectFilter, sortOrder]) const viewDotEnvRaw = useMemo( () => (viewingEntry ? formatDotEnv(viewingEntry.variables).trim() : ""), @@ -132,20 +150,25 @@ export function EnvironmentSetList() { } return ( - <> + + } + >
- {isMobile && ( -
- -

{t("title")}

-
- )} -
@@ -495,6 +518,6 @@ export function EnvironmentSetList() { - + ) } diff --git a/apps/desktop-ui/src/components/mobile-nav.tsx b/apps/desktop-ui/src/components/mobile-nav.tsx index 0c30dfb0..5e4fdb8b 100644 --- a/apps/desktop-ui/src/components/mobile-nav.tsx +++ b/apps/desktop-ui/src/components/mobile-nav.tsx @@ -3,7 +3,6 @@ import Link from "next/link" import { usePathname, useRouter } from "next/navigation" import { Home, LayoutGrid, Lock, User as UserIcon, Moon, Sun, Settings, HelpCircle } from "lucide-react" import { cn } from "@/lib/utils" -import { useSidebar } from "@/components/ui/sidebar" import { DropdownMenu, DropdownMenuContent, @@ -30,7 +29,6 @@ const navItems = [ export function MobileNav() { const pathname = usePathname() - const { toggleSidebar, openMobile } = useSidebar() const user = useAppUser() const { clearPasswords } = usePasswordStore() const { clearSets } = useEnvironmentManagerStore() @@ -47,9 +45,8 @@ export function MobileNav() { }, []) // Determine active tab for indicator animation - const getActiveTab = (): 'home' | 'tools' | 'profile' | null => { + const getActiveTab = (): 'home' | 'profile' | null => { if (pathname === '/dashboard') return 'home' - if (openMobile) return 'tools' return null } const activeTab = getActiveTab() @@ -136,24 +133,15 @@ export function MobileNav() { {navItems[0].label} - {/* Tools Button */} + {/* Tools Button — opens the global command palette (same event the + TopBar search button dispatches). The old sidebar it used to + toggle is gone; the palette is the tool switcher now. */} diff --git a/apps/desktop-ui/src/components/notes/NotesSidebar.tsx b/apps/desktop-ui/src/components/notes/NotesSidebar.tsx index 1435538d..bdf48fc5 100644 --- a/apps/desktop-ui/src/components/notes/NotesSidebar.tsx +++ b/apps/desktop-ui/src/components/notes/NotesSidebar.tsx @@ -29,7 +29,6 @@ import { ArrowUpDown, NotebookPen, FileText, - PanelLeftClose, ArrowUp, ArrowDown, } from "lucide-react"; @@ -56,6 +55,7 @@ import { AlertDialogTitle, } from "@/components/ui/alert-dialog"; import { Note } from "@/app/app/notes/types/Note"; +import { ToolSidebarActions, useToolSidebarPanel } from "@/components/tools/tool-sidebar"; import { useTranslations } from "next-intl"; import { extractSnippet } from "@/app/app/notes/utils/noteContentUtils"; import { type ChildrenMap, type SortDir, type SortKey, buildChildrenMap, getCachedPlainText } from "./notes-helpers"; @@ -83,6 +83,7 @@ const NoteItem = React.memo(({ const t = useTranslations("Notes.sidebar"); const { activeNoteId, setActiveNoteId } = useNotesUI(); const { createNote, pinNote, duplicateNote, updateNote } = useNotesActions(); + const panel = useToolSidebarPanel(); const [emojiPickerOpen, setEmojiPickerOpen] = useState(false); const children = childrenMap.get(note.id) ?? []; @@ -98,6 +99,9 @@ const NoteItem = React.memo(({ const handleSelect = () => { setActiveNoteId(note.id); onExpandPath(note.id); + // On mobile the panel is a sheet over the editor — close it so the + // opened note is actually visible. + if (panel?.isMobile) panel.close(); }; const handleCreateChild = async (e: React.MouseEvent) => { @@ -269,7 +273,7 @@ NoteItem.displayName = "NoteItem"; export default function NotesSidebar() { const t = useTranslations("Notes.sidebar"); const { notes, noteById, isLoading, searchIndexReady } = useNotesData(); - const { activeNoteId, setSidebarOpen } = useNotesUI(); + const { activeNoteId } = useNotesUI(); const { createNote, deleteNote, moveNote, warmSearchIndex } = useNotesActions(); const [noteToDelete, setNoteToDelete] = useState(null); const [noteToMove, setNoteToMove] = useState(null); @@ -404,17 +408,11 @@ export default function NotesSidebar() { return ( <> -
-
-
-
- - - - {t("title")} -
-
- + {/* Header (icon, title, collapse) is owned by ToolSidebarLayout in + app/app/notes/notes-client-layout.tsx. Sort and create ride the + actions portal because their state is local to this component. */} + + - {/* Collapse — desktop only; on mobile the sidebar lives in a Sheet */} - -
-
+ + + + +
-
+
{isLoading ? (
{t("loading")}
@@ -585,7 +573,6 @@ export default function NotesSidebar() { )}
-
!open && setNoteToDelete(null)}> diff --git a/apps/desktop-ui/src/components/password-manager/password-list.tsx b/apps/desktop-ui/src/components/password-manager/password-list.tsx index 46782f61..59cfca67 100644 --- a/apps/desktop-ui/src/components/password-manager/password-list.tsx +++ b/apps/desktop-ui/src/components/password-manager/password-list.tsx @@ -25,12 +25,17 @@ import { calculatePasswordStrength, getStrengthColor, getFaviconUrl, getPassword import { usePasswordStrengthReady } from "@/lib/use-password-strength" import { useVaultIconsEnabled } from "@/lib/vault-icon-pref" import { FaviconImg } from "@/components/favicon-img" +import { IconShieldLock } from "@tabler/icons-react" +import { + ToolSidebarFilterList, + ToolSidebarLayout, + type ToolSidebarFilterItem, +} from "@/components/tools/tool-sidebar" import { Badge } from "@/components/ui/badge" import { formatDistanceToNow } from "date-fns" import { ImportExportDialog } from "./import-export-dialog" import { useIsMobile } from "@/components/hooks/use-mobile" import { PasswordItemSwipeable } from "./password-item-swipeable" -import { SidebarTrigger } from "@/components/ui/sidebar" import { PasswordCard } from "./password-card" import { SecurityDashboard } from "./security-dashboard" import { BreachCheckDialog } from "./breach-check-dialog" @@ -52,6 +57,7 @@ export function PasswordList() { const [visiblePasswords, setVisiblePasswords] = useState>(new Set()) const [viewMode, setViewMode] = useState<"grid" | "list">("grid") const [quickFilter, setQuickFilter] = useState<"all" | "weak" | "reused" | "no-url">("all") + const [tagFilter, setTagFilter] = useState("all") const [sortBy, setSortBy] = useState<"name" | "date" | "strength">("name") const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false) const [passwordToDelete, setPasswordToDelete] = useState(null) @@ -91,13 +97,16 @@ export function PasswordList() { } else if (quickFilter === "no-url") { filtered = bySearch.filter((entry) => !entry.url?.trim()) } + if (tagFilter !== "all") { + filtered = filtered.filter((entry) => entry.tags?.includes(tagFilter)) + } return [...filtered].sort((a, b) => { if (sortBy === "date") return b.updatedAt - a.updatedAt if (sortBy === "strength") return calculatePasswordStrength(b.password) - calculatePasswordStrength(a.password) return a.service.localeCompare(b.service) }) - }, [passwords, searchTerm, quickFilter, reusedPasswordIds, sortBy, strengthReady]) + }, [passwords, searchTerm, quickFilter, tagFilter, reusedPasswordIds, sortBy, strengthReady]) const weakCount = useMemo( () => passwords.filter((entry) => calculatePasswordStrength(entry.password) <= 2).length, @@ -111,6 +120,21 @@ export function PasswordList() { const reusedCount = reusedPasswordIds.size const hasActiveQuickFilter = quickFilter !== "all" + const securityFilters: ToolSidebarFilterItem[] = [ + { id: "all", label: t("filters.all"), count: passwords.length }, + { id: "weak", label: t("filters.weak"), count: weakCount, icon: AlertTriangle }, + { id: "reused", label: t("filters.reused"), count: reusedCount, icon: Repeat }, + { id: "no-url", label: t("filters.noUrl"), count: noUrlCount, icon: Link2Off }, + ] + + const tagFilters: ToolSidebarFilterItem[] = useMemo(() => { + const counts = new Map() + passwords.forEach((p) => p.tags?.forEach((tag) => counts.set(tag, (counts.get(tag) ?? 0) + 1))) + return [...counts.entries()] + .sort((a, b) => a[0].localeCompare(b[0])) + .map(([tag, count]) => ({ id: tag, label: tag, count })) + }, [passwords]) + const runBreachCheck = async () => { if (breachStatus === "checking") return setBreachStatus("checking") @@ -236,6 +260,7 @@ export function PasswordList() { const clearFilters = () => { setSearchTerm("") setQuickFilter("all") + setTagFilter("all") } useEffect(() => { @@ -324,15 +349,43 @@ export function PasswordList() { } return ( + + + + } + sidebar={ +
+ setQuickFilter(id as typeof quickFilter)} + heading={t("filters.heading")} + /> + {tagFilters.length > 0 && ( + + )} +
+ } + >
{/* Mobile Header */} {isMobile && (
- -
)} - {!isMobile && ( + {/* Security and tag facets moved to the ToolSidebarLayout panel; the + reset shortcut stays here next to the search it also clears. */} + {!isMobile && (hasActiveQuickFilter || tagFilter !== "all" || searchTerm) && (
- - - - - {(hasActiveQuickFilter || searchTerm) && ( - - )}
Cmd/Ctrl+K search • Cmd/Ctrl+Shift+L lock
@@ -796,5 +837,6 @@ export function PasswordList() {
+ ) } diff --git a/apps/desktop-ui/src/components/s3-drive/bucket-sidebar.tsx b/apps/desktop-ui/src/components/s3-drive/bucket-sidebar.tsx index b633f2e8..6532c4f9 100644 --- a/apps/desktop-ui/src/components/s3-drive/bucket-sidebar.tsx +++ b/apps/desktop-ui/src/components/s3-drive/bucket-sidebar.tsx @@ -4,7 +4,6 @@ import { useState } from "react" import { toast } from "sonner" import { cn } from "@/lib/utils" import { Button } from "@/components/ui/button" -import { ScrollArea } from "@/components/ui/scroll-area" import { DropdownMenu, DropdownMenuContent, @@ -24,6 +23,7 @@ import { import { IconBucket, IconPlus, IconDots, IconPencil, IconTrash, IconBrandAws, IconCloud, IconServer } from "@tabler/icons-react" import { deleteConnection } from "@/lib/s3-drive-api" import { useS3DriveStore, type DecryptedConnection } from "@/store/s3-drive-store" +import { useToolSidebarPanel } from "@/components/tools/tool-sidebar" import { AddBucketDialog } from "./add-bucket-dialog" type Props = { @@ -60,6 +60,7 @@ function connSubtitle(conn: DecryptedConnection): string { export function BucketSidebar({ encryptionKey }: Props) { const { connections, activeConnectionId, setActiveConnection, removeConnection } = useS3DriveStore() + const panel = useToolSidebarPanel() const [addOpen, setAddOpen] = useState(false) const [editTarget, setEditTarget] = useState(null) const [deleteTarget, setDeleteTarget] = useState(null) @@ -80,44 +81,24 @@ export function BucketSidebar({ encryptionKey }: Props) { } } + // Header (icon, title, collapse) is owned by ToolSidebarLayout in + // app/app/s3-drive/page.tsx — this renders the panel body only. return ( -
- {/* Header */} -
-
-
-
- -
- Buckets -
- -
- {connections.length > 0 && ( -

- {connections.length} connection{connections.length !== 1 ? "s" : ""} -

- )} -
- -
+ <> + {connections.length > 0 && ( +

+ {connections.length} connection{connections.length !== 1 ? "s" : ""} +

+ )} - -
+
{connections.length === 0 && (
-
+
@@ -149,7 +130,12 @@ export function BucketSidebar({ encryptionKey }: Props) { ? "bg-primary/8 text-foreground shadow-sm ring-1 ring-primary/10" : "hover:bg-accent/50 text-muted-foreground hover:text-foreground", )} - onClick={() => setActiveConnection(conn.id)} + onClick={() => { + setActiveConnection(conn.id) + // On mobile the panel is a sheet covering the + // browser — close it so the pick is visible. + if (panel?.isMobile) panel.close() + }} > {/* Active indicator line */}
) })} -
- +
{/* Footer */} {connections.length > 0 && ( -
+
+ ) } diff --git a/apps/desktop-ui/src/components/sidebar/app-sidebar.tsx b/apps/desktop-ui/src/components/sidebar/app-sidebar.tsx deleted file mode 100644 index 0f6c7f3a..00000000 --- a/apps/desktop-ui/src/components/sidebar/app-sidebar.tsx +++ /dev/null @@ -1,82 +0,0 @@ -'use client' -import Link from 'next/link' -import { usePathname } from 'next/navigation' -import { motion } from 'framer-motion' -import { cn } from '@/lib/utils' -import { - Sidebar, - SidebarContent, - SidebarRail, -} from '@/components/ui/sidebar' -import { NavGroup } from './nav-group' -import { SidebarMenu, SidebarMenuButton, SidebarMenuItem } from '@/components/ui/sidebar' -import { LayoutDashboard, Sparkles } from 'lucide-react' -import { usePinnedToolsForActiveWorkspace } from '@/store/pinned-tools-store' -import { useActiveWorkspace } from '@/store/workspace-store' -import { IconPin } from '@tabler/icons-react' -import { buildPinnedNavItems } from './app-sidebar.helpers' - -export function AppSidebar({ ...props }: React.ComponentProps) { - const pinnedTools = usePinnedToolsForActiveWorkspace() - const activeWs = useActiveWorkspace() - const pinnedNavItems = buildPinnedNavItems(pinnedTools, activeWs) - const pathname = usePathname(); - const isDashboardActive = pathname === '/dashboard'; - - return ( - - {/* Brand lockup moved to the unified TopBar (components/shell/top-bar.tsx). - The sidebar is now pure navigation. */} - - - - - - {isDashboardActive && ( - - - - )} - - Dashboard - - - - - {pinnedNavItems.length > 0 ? ( - - ) : ( -
- -

- No pinned tools yet. -

- - Browse all tools → - -
- )} -
- -
- ) -} diff --git a/apps/desktop-ui/src/components/sidebar/client-layout.tsx b/apps/desktop-ui/src/components/sidebar/client-layout.tsx index 46d48d8d..7b345565 100644 --- a/apps/desktop-ui/src/components/sidebar/client-layout.tsx +++ b/apps/desktop-ui/src/components/sidebar/client-layout.tsx @@ -1,7 +1,6 @@ 'use client' import React, { useEffect } from 'react'; import { usePathname } from 'next/navigation'; -import { SidebarProvider, useSidebar } from "@/components/ui/sidebar"; import { NavBar } from '@/components/nav-bar'; import { MobileNav } from '@/components/mobile-nav'; import { TopBar } from '@/components/shell/top-bar'; @@ -58,7 +57,6 @@ function TabSyncer() { } function Layout({ children }: { children: React.ReactNode }) { - const { state } = useSidebar(); const pathname = usePathname(); const { tabs } = useTabStore(); // Keying tool content by workspace remounts every mounted tab (and page) on @@ -69,17 +67,11 @@ function Layout({ children }: { children: React.ReactNode }) { const inTabMode = isTabRoute(pathname) && tabs.length > 0; return ( -
+
-
+
{!inTabMode && } @@ -119,9 +111,5 @@ export function ClientLayout({ children }: { children: React.ReactNode }) { } }, []); - return ( - - {children} - - ); + return {children}; } diff --git a/apps/desktop-ui/src/components/sidebar/nav-group.tsx b/apps/desktop-ui/src/components/sidebar/nav-group.tsx deleted file mode 100644 index dad65ef4..00000000 --- a/apps/desktop-ui/src/components/sidebar/nav-group.tsx +++ /dev/null @@ -1,408 +0,0 @@ -"use client"; - -import { ReactNode } from "react"; -import Link from "next/link"; -import { usePathname } from "next/navigation"; -import { ChevronRight } from "lucide-react"; -import { motion } from "framer-motion"; -import { cn } from "@/lib/utils"; -import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from "../ui/collapsible"; -import { Tooltip, TooltipTrigger, TooltipContent } from "../ui/tooltip"; -import { - SidebarGroup, - SidebarGroupLabel, - SidebarGroupContent, - SidebarMenu, - SidebarMenuButton, - SidebarMenuItem, - SidebarMenuSub, - SidebarMenuSubButton, - SidebarMenuSubItem, - useSidebar, -} from "@/components/ui/sidebar"; -import { Badge } from "../ui/badge"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "../ui/dropdown-menu"; -import { NavCollapsible, NavItem, NavLink } from "./types"; // Import types -import useAuth from "@/utils/useAuth"; // Import useAuth -import { useTranslations } from "next-intl"; -import { getToolMessageKey } from "@/lib/tool-i18n"; -import { Star } from "lucide-react"; -import { usePinnedToolsStore, usePinnedToolsForActiveWorkspace } from "@/store/pinned-tools-store"; -import { useWorkspaceStore } from "@/store/workspace-store"; -import { useToolPermission } from "@/lib/workspace-rbac"; -import { useActiveWorkspace } from "@/store/workspace-store"; -import { sidebarUrlToToolSlug } from "@/lib/sidebar-tool-slug"; - -// Define the props interface for NavGroup -interface NavGroupProps { - title: string; - titleKey?: string; - items: (NavLink | NavCollapsible)[]; - collapsible?: boolean; - icon?: React.ElementType; - hiddenOnMobile?: boolean; -} - -/** - * Role-gated wrapper around a single NavLink item. - * For non-/app URLs or personal workspaces, always renders. - * For shared workspaces, hides the link when the active role lacks "read". - */ -function NavLinkGated({ - item, - href, -}: { - item: NavLink; - href: string; -}) { - const itemUrl = typeof item.url === "string" ? item.url : item.url.toString(); - const slug = sidebarUrlToToolSlug(itemUrl); - const canRead = useToolPermission(slug ?? "", "read"); - const activeWs = useActiveWorkspace(); - - // Non-matrix URLs (e.g. /dashboard) and personal workspaces always render. - if (!slug || activeWs?.is_personal) { - return ; - } - if (!canRead) return null; - return ; -} - -// Main NavGroup Component -export function NavGroup({ title, titleKey, items, collapsible, icon: Icon, hiddenOnMobile }: NavGroupProps) { - const { state, isMobile } = useSidebar(); - const pathname = usePathname(); - const { user, loading } = useAuth(false); // Check auth state with loading - const tNav = useTranslations("Navigation"); - const groupTitle = titleKey ? tNav(titleKey as never) : title; - - // Drop collapsible groups that ended up with no children. - const visibleItems = items.filter((item) => { - if ("items" in item && Array.isArray((item as NavCollapsible).items) && (item as NavCollapsible).items.length === 0) return false; - return true; - }); - - if (visibleItems.length === 0) { - return null; - } - - const content = ( - - {visibleItems.map((item) => { - const key = `${item.title}-${item.url}`; - - if (!("items" in item)) { - const itemUrl = - typeof item.url === "string" ? item.url : item.url.toString(); - return ( - - ); - } - - if (state === "collapsed") { - return ( - - ); - } - - return ( - - ); - })} - - ); - - if (collapsible) { - return ( - - - - - {Icon && ( - - - - - {groupTitle} - - )} - {groupTitle} - - - - - - {content} - - - - - ); - } - - return ( - - - {Icon && ( - - - - - {groupTitle} - - )} - {groupTitle} - - {content} - - ); -} - -// SidebarMenuLink Component -const SidebarMenuLink = ({ - item, - href, -}: { - item: NavLink; - href: string; -}) => { - const { setOpenMobile, state } = useSidebar(); - const tNav = useTranslations("Navigation"); - const togglePinKeyed = usePinnedToolsStore((s) => s.togglePin); - const activeWorkspaceId = useWorkspaceStore((s) => s.activeWorkspaceId); - const togglePin = (url: string) => { - if (activeWorkspaceId) togglePinKeyed(activeWorkspaceId, url) - }; - const pinnedTools = usePinnedToolsForActiveWorkspace(); - const itemUrl = - typeof item.url === "string" ? item.url : item.url.toString(); - const toolKey = getToolMessageKey(itemUrl); - const displayTitle = toolKey ? tNav(toolKey as never) : item.title; - const isPinned = pinnedTools.includes(itemUrl); - - const isActive = checkIsActive(href, item); - - return ( - - - - {isActive && ( - - - - )} - {item.icon && ( - - )} - - {displayTitle} - - {item.badge && {item.badge}} - - - {state !== "collapsed" && ( - - )} - - ); -}; - -// SidebarMenuCollapsible Component -const SidebarMenuCollapsible = ({ - item, - href, -}: { - item: NavCollapsible; - href: string; -}) => { - const { setOpenMobile } = useSidebar(); - const { user, loading } = useAuth(false); - const tNav = useTranslations("Navigation"); - const parentLabel = item.titleKey ? tNav(item.titleKey as never) : item.title; - - return ( - - - - - {item.icon && } - {parentLabel} - {item.badge && {item.badge}} - - - -
- - - {item.items.map((subItem) => { - const subItemUrl = - typeof subItem.url === "string" - ? subItem.url - : subItem.url.toString(); - const isSubActive = checkIsActive(href, subItem); - - return ( - - - setOpenMobile(false)} - className="relative flex items-center pl-4 overflow-hidden" - > - {isSubActive && ( - <> - - - - )} - {subItem.icon && } - {subItem.title} - {subItem.badge && {subItem.badge}} - - - - ); - })} - - -
-
-
- ); -}; - -// SidebarMenuCollapsedDropdown Component -const SidebarMenuCollapsedDropdown = ({ - item, - href, -}: { - item: NavCollapsible; - href: string; -}) => { - const { user, loading } = useAuth(false); - const tNav = useTranslations("Navigation"); - const parentLabel = item.titleKey ? tNav(item.titleKey as never) : item.title; - - return ( - -
- - - - {item.icon && } - {parentLabel} - {item.badge && {item.badge}} - - - - - - {parentLabel} {item.badge ? `(${item.badge})` : ""} - - - {item.items.map((sub) => { - const subUrl = - typeof sub.url === "string" ? sub.url : sub.url.toString(); - return ( - - - {sub.icon && } - {sub.title} - {sub.badge && ( - {sub.badge} - )} - - - ); - })} - - -
-
- ); -}; - -// Helper Components and Functions -const NavBadge = ({ children }: { children: ReactNode }) => ( - {children} -); - -function checkIsActive(href: string, item: NavItem, mainNav = false) { - return ( - href === item.url || - href.split("?")[0] === item.url || - !!item?.items?.filter((i) => i.url === href).length || - (mainNav && - href.split("/")[1] !== "" && - href.split("/")[1] === - (typeof item.url === "string" ? item.url.split("/")[1] : "")) - ); -} \ No newline at end of file diff --git a/apps/desktop-ui/src/components/snippet-manager/snippet-manager-tool.tsx b/apps/desktop-ui/src/components/snippet-manager/snippet-manager-tool.tsx index 77ab1af5..2fe6b3b9 100644 --- a/apps/desktop-ui/src/components/snippet-manager/snippet-manager-tool.tsx +++ b/apps/desktop-ui/src/components/snippet-manager/snippet-manager-tool.tsx @@ -68,11 +68,7 @@ import { SheetTitle, SheetTrigger, } from "@/components/ui/sheet"; -import { - ResizableHandle, - ResizablePanel, - ResizablePanelGroup, -} from "@/components/ui/resizable"; +import { ToolSidebarActions, ToolSidebarLayout } from "@/components/tools/tool-sidebar"; import { cn } from "@/lib/utils"; import { SnippetMonaco, @@ -899,38 +895,21 @@ export function SnippetManagerTool() { // ── Desktop layout ──────────────────────────────────────────────────────── - return ( - -
- + {/* Header (icon, title, collapse) is owned by ToolSidebarLayout. */} + + -
-
+ + + {/* Search */}
@@ -965,25 +944,29 @@ export function SnippetManagerTool() { }} /> - {/* Count */} - {filtered.length > 0 && ( -
-

- {snHasMore ? `${snDisplayCount} / ${filtered.length}` : filtered.length}{" "} - {filtered.length === 1 ? "snippet" : "snippets"} -

-
- )} -
- - - + {/* Count */} + {filtered.length > 0 && ( +
+

+ {snHasMore ? `${snDisplayCount} / ${filtered.length}` : filtered.length}{" "} + {filtered.length === 1 ? "snippet" : "snippets"} +

+
+ )} + + ); - {/* Editor panel */} - - {editorPanel} - - + return ( + +
+
+ {editorPanel} +
- +
); } diff --git a/apps/desktop-ui/src/components/tools/tool-sidebar.tsx b/apps/desktop-ui/src/components/tools/tool-sidebar.tsx new file mode 100644 index 00000000..04169bd6 --- /dev/null +++ b/apps/desktop-ui/src/components/tools/tool-sidebar.tsx @@ -0,0 +1,262 @@ +'use client' + +import * as React from 'react' +import { createPortal } from 'react-dom' +import { PanelLeftClose, PanelLeftOpen } from 'lucide-react' +import { useTranslations } from 'next-intl' +import { Button } from '@/components/ui/button' +import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet' +import { useIsMobile } from '@/components/hooks/use-mobile' +import { useToolSidebarStore } from '@/store/tool-sidebar-store' +import { toolCategoryMap } from '@/lib/tool-categories' +import { categoryAccent } from '@/components/dashboard/types' +import { cn } from '@/lib/utils' + +/** + * The one in-tool left panel. Every app-like tool (notes, bookmarks, api-client, + * data-explorer, s3-drive, snippet-manager, password-manager, api-keys, + * environment-manager, to-do) renders its list/tree through this so the chrome — + * width, header, collapse affordance, mobile sheet — is identical everywhere. + * + * Desktop collapse state is persisted per tool; the mobile sheet is ephemeral. + */ + +interface ToolSidebarContextValue { + /** True when the panel is inside the mobile sheet rather than the column. */ + isMobile: boolean + /** + * Dismiss the panel. No-op on desktop when the panel is pinned open — call it + * from list rows so picking an item on mobile closes the sheet instead of + * leaving it covering the result. + */ + close: () => void +} + +const ToolSidebarContext = React.createContext(null) +const ActionsSlotContext = React.createContext(null) + +/** Panel-side helper. Returns null outside a ToolSidebarLayout. */ +export function useToolSidebarPanel() { + return React.useContext(ToolSidebarContext) +} + +/** + * Renders header buttons into the panel header from *inside* the panel, so a + * sidebar whose actions depend on its own local state (sort order, filters) + * doesn't have to lift that state into the page just to reach the header. + * Prefer the `actions` prop when the page already owns the handler. + */ +export function ToolSidebarActions({ children }: { children: React.ReactNode }) { + const slot = React.useContext(ActionsSlotContext) + return slot ? createPortal(children, slot) : null +} + +export interface ToolSidebarFilterItem { + id: string + label: string + count?: number + /** Tailwind bg-* class for a leading status dot. */ + dot?: string + icon?: React.ElementType +} + +/** + * The standard panel body for tools that group items by a facet (tags, project, + * environment, status) rather than a tree. One row per bucket, with counts. + */ +export function ToolSidebarFilterList({ + items, + value, + onChange, + heading, + className, +}: { + items: ToolSidebarFilterItem[] + value: string + onChange: (id: string) => void + /** Optional uppercase section label above the rows. */ + heading?: string + className?: string +}) { + const panel = useToolSidebarPanel() + return ( +
+ {heading && ( +

+ {heading} +

+ )} + {items.map((item) => { + const Icon = item.icon + const active = item.id === value + return ( + + ) + })} +
+ ) +} + +interface ToolSidebarLayoutProps { + /** Stable id — also the persistence key for the collapse state. */ + toolId: string + icon: React.ElementType + title: string + /** Header buttons (add, sort, filter…) rendered left of the collapse toggle. */ + actions?: React.ReactNode + /** + * Panel body: the tool's list, tree, or filter set. Laid out as a flex column + * that does NOT scroll — the panel owns its own `overflow-y-auto` region so a + * search field or footer can stay pinned while the list scrolls under it. + */ + sidebar: React.ReactNode + children: React.ReactNode + className?: string +} + +export function ToolSidebarLayout({ + toolId, + icon: Icon, + title, + actions, + sidebar, + children, + className, +}: ToolSidebarLayoutProps) { + const t = useTranslations('ToolSidebar') + const isMobile = useIsMobile() + const collapsed = useToolSidebarStore((s) => !!s.collapsed[toolId]) + const setCollapsed = useToolSidebarStore((s) => s.setCollapsed) + const [sheetOpen, setSheetOpen] = React.useState(false) + const [actionsSlot, setActionsSlot] = React.useState(null) + // Same category tint the dashboard card for this tool uses, so the identity + // colour is continuous from the grid to the tool. Falls back to primary. + const accent = categoryAccent(toolCategoryMap[toolId] ?? '') + + const close = React.useCallback(() => { + if (isMobile) setSheetOpen(false) + else setCollapsed(toolId, true) + }, [isMobile, setCollapsed, toolId]) + + const ctx = React.useMemo( + () => ({ isMobile, close }), + [isMobile, close], + ) + + const panel = ( +
+
+
+ + + + {title} +
+
+ {actions} +
+ {!isMobile && ( + + )} +
+
+ +
{sidebar}
+
+
+ ) + + const hidden = isMobile || collapsed + + return ( + +
+ {!isMobile && !collapsed &&
{panel}
} + + {isMobile && ( + + + + {title} + + {panel} + + + )} + + {/* Re-open lives in a 40px rail, not a floating overlay: every tool's + main pane already puts a toolbar or header at the top-left, and an + absolutely-positioned button would sit on top of it. */} + {hidden && ( +
+ +
+ )} + + {/* Same surface the 71 single-pane tools paint (dashboard-grid-bg + + dash-ambient), so moving between a converter and a workspace tool + doesn't change the background under you. */} +
+
+ {children} +
+
+ + ) +} diff --git a/apps/desktop-ui/src/components/ui/sidebar.tsx b/apps/desktop-ui/src/components/ui/sidebar.tsx deleted file mode 100644 index bbb986c6..00000000 --- a/apps/desktop-ui/src/components/ui/sidebar.tsx +++ /dev/null @@ -1,768 +0,0 @@ -"use client" - -import * as React from "react" -import { Slot } from "@radix-ui/react-slot" -import { VariantProps, cva } from "class-variance-authority" -import { ChevronLeft, ChevronRight, PanelLeft } from "lucide-react" - -import { useIsMobile } from "../hooks/use-mobile" -import { cn } from "@/lib/utils" -import { Button } from "@/components/ui/button" -import { Input } from "@/components/ui/input" -import { Separator } from "@/components/ui/separator" -import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet" -import { Drawer, DrawerContent, DrawerTitle } from "@/components/ui/drawer" -import { Skeleton } from "@/components/ui/skeleton" -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@/components/ui/tooltip" - -const SIDEBAR_COOKIE_NAME = "sidebar:state" -const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7 -const SIDEBAR_WIDTH = "16rem" -const SIDEBAR_WIDTH_MOBILE = "20rem" -const SIDEBAR_WIDTH_ICON = "3rem" -const SIDEBAR_KEYBOARD_SHORTCUT = "b" - -type SidebarContext = { - state: "expanded" | "collapsed" - open: boolean - setOpen: (open: boolean) => void - openMobile: boolean - setOpenMobile: (open: boolean) => void - isMobile: boolean - toggleSidebar: () => void -} - -const SidebarContext = React.createContext(null) - -function useSidebar() { - const context = React.useContext(SidebarContext) - if (!context) { - throw new Error("useSidebar must be used within a SidebarProvider.") - } - - return context -} - -const SidebarProvider = React.forwardRef< - HTMLDivElement, - React.ComponentProps<"div"> & { - defaultOpen?: boolean - open?: boolean - onOpenChange?: (open: boolean) => void - } ->( - ( - { - defaultOpen = true, - open: openProp, - onOpenChange: setOpenProp, - className, - style, - children, - ...props - }, - ref - ) => { - const isMobile = useIsMobile() - const [openMobile, setOpenMobile] = React.useState(false) - - // This is the internal state of the sidebar. - // We use openProp and setOpenProp for control from outside the component. - const [_open, _setOpen] = React.useState(defaultOpen) - const open = openProp ?? _open - const setOpen = React.useCallback( - (value: boolean | ((value: boolean) => boolean)) => { - const openState = typeof value === "function" ? value(open) : value - if (setOpenProp) { - setOpenProp(openState) - } else { - _setOpen(openState) - } - - // This sets the cookie to keep the sidebar state. - document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}` - }, - [setOpenProp, open] - ) - - // Helper to toggle the sidebar. - const toggleSidebar = React.useCallback(() => { - return isMobile - ? setOpenMobile((open) => !open) - : setOpen((open) => !open) - }, [isMobile, setOpen, setOpenMobile]) - - // Adds a keyboard shortcut to toggle the sidebar. - React.useEffect(() => { - const handleKeyDown = (event: KeyboardEvent) => { - if ( - event.key === SIDEBAR_KEYBOARD_SHORTCUT && - (event.metaKey || event.ctrlKey) - ) { - event.preventDefault() - toggleSidebar() - } - } - - window.addEventListener("keydown", handleKeyDown) - return () => window.removeEventListener("keydown", handleKeyDown) - }, [toggleSidebar]) - - // We add a state so that we can do data-state="expanded" or "collapsed". - // This makes it easier to style the sidebar with Tailwind classes. - const state = open ? "expanded" : "collapsed" - - const contextValue = React.useMemo( - () => ({ - state, - open, - setOpen, - isMobile, - openMobile, - setOpenMobile, - toggleSidebar, - }), - [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar] - ) - - return ( - - -
- {children} -
-
-
- ) - } -) -SidebarProvider.displayName = "SidebarProvider" - -const Sidebar = React.forwardRef< - HTMLDivElement, - React.ComponentProps<"div"> & { - side?: "left" | "right" - variant?: "sidebar" | "floating" | "inset" - collapsible?: "offcanvas" | "icon" | "none" - } ->( - ( - { - side = "left", - variant = "sidebar", - collapsible = "offcanvas", - className, - children, - ...props - }, - ref - ) => { - const { isMobile, state, openMobile, setOpenMobile } = useSidebar() - - if (collapsible === "none") { - return ( -
- {children} -
- ) - } - - if (isMobile) { - return ( - - - Navigation Menu -
- {children} -
-
-
- ) - } - - return ( -
- {/* This is what handles the sidebar gap on desktop */} -
- -
- ) - } -) -Sidebar.displayName = "Sidebar" - -const SidebarTrigger = React.forwardRef< - React.ElementRef, - React.ComponentProps ->(({ className, onClick, ...props }, ref) => { - const { toggleSidebar } = useSidebar() - - return ( - - ) -}) -SidebarTrigger.displayName = "SidebarTrigger" - -const SidebarRail = React.forwardRef< - HTMLButtonElement, - React.ComponentProps<"button"> ->(({ className, ...props }, ref) => { - const { toggleSidebar, state } = useSidebar() - const isCollapsed = state === "collapsed" - - return ( - - ) -}) -SidebarRail.displayName = "SidebarRail" - -const SidebarInset = React.forwardRef< - HTMLDivElement, - React.ComponentProps<"main"> ->(({ className, ...props }, ref) => { - return ( -
- ) -}) -SidebarInset.displayName = "SidebarInset" - -const SidebarInput = React.forwardRef< - React.ElementRef, - React.ComponentProps ->(({ className, ...props }, ref) => { - return ( - - ) -}) -SidebarInput.displayName = "SidebarInput" - -const SidebarHeader = React.forwardRef< - HTMLDivElement, - React.ComponentProps<"div"> ->(({ className, ...props }, ref) => { - return ( -
- ) -}) -SidebarHeader.displayName = "SidebarHeader" - -const SidebarFooter = React.forwardRef< - HTMLDivElement, - React.ComponentProps<"div"> ->(({ className, ...props }, ref) => { - return ( -
- ) -}) -SidebarFooter.displayName = "SidebarFooter" - -const SidebarSeparator = React.forwardRef< - React.ElementRef, - React.ComponentProps ->(({ className, ...props }, ref) => { - return ( - - ) -}) -SidebarSeparator.displayName = "SidebarSeparator" - -const SidebarContent = React.forwardRef< - HTMLDivElement, - React.ComponentProps<"div"> ->(({ className, ...props }, ref) => { - return ( -
- ) -}) -SidebarContent.displayName = "SidebarContent" - -const SidebarGroup = React.forwardRef< - HTMLDivElement, - React.ComponentProps<"div"> ->(({ className, ...props }, ref) => { - return ( -
- ) -}) -SidebarGroup.displayName = "SidebarGroup" - -const SidebarGroupLabel = React.forwardRef< - HTMLDivElement, - React.ComponentProps<"div"> & { asChild?: boolean } ->(({ className, asChild = false, ...props }, ref) => { - const Comp = asChild ? Slot : "div" - - return ( - svg]:size-4 [&>svg]:shrink-0", - "group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0", - className - )} - {...props} - /> - ) -}) -SidebarGroupLabel.displayName = "SidebarGroupLabel" - -const SidebarGroupAction = React.forwardRef< - HTMLButtonElement, - React.ComponentProps<"button"> & { asChild?: boolean } ->(({ className, asChild = false, ...props }, ref) => { - const Comp = asChild ? Slot : "button" - - return ( - svg]:size-4 [&>svg]:shrink-0", - // Increases the hit area of the button on mobile. - "after:absolute after:-inset-2 after:md:hidden", - "group-data-[collapsible=icon]:hidden", - className - )} - {...props} - /> - ) -}) -SidebarGroupAction.displayName = "SidebarGroupAction" - -const SidebarGroupContent = React.forwardRef< - HTMLDivElement, - React.ComponentProps<"div"> ->(({ className, ...props }, ref) => ( -
-)) -SidebarGroupContent.displayName = "SidebarGroupContent" - -const SidebarMenu = React.forwardRef< - HTMLUListElement, - React.ComponentProps<"ul"> ->(({ className, ...props }, ref) => ( -
    -)) -SidebarMenu.displayName = "SidebarMenu" - -const SidebarMenuItem = React.forwardRef< - HTMLLIElement, - React.ComponentProps<"li"> ->(({ className, ...props }, ref) => ( -
  • -)) -SidebarMenuItem.displayName = "SidebarMenuItem" - -const sidebarMenuButtonVariants = cva( - "peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0", - { - variants: { - variant: { - default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground", - outline: - "bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]", - }, - size: { - default: "h-8 text-sm", - sm: "h-7 text-xs", - lg: "h-12 text-sm group-data-[collapsible=icon]:!p-0", - }, - }, - defaultVariants: { - variant: "default", - size: "default", - }, - } -) - -const SidebarMenuButton = React.forwardRef< - HTMLButtonElement, - React.ComponentProps<"button"> & { - asChild?: boolean - isActive?: boolean - tooltip?: string | React.ComponentProps - } & VariantProps ->( - ( - { - asChild = false, - isActive = false, - variant = "default", - size = "default", - tooltip, - className, - ...props - }, - ref - ) => { - const Comp = asChild ? Slot : "button" - const { isMobile, state } = useSidebar() - - const button = ( - - ) - - if (!tooltip) { - return button - } - - if (typeof tooltip === "string") { - tooltip = { - children: tooltip, - } - } - - return ( - - {button} - - ) - } -) -SidebarMenuButton.displayName = "SidebarMenuButton" - -const SidebarMenuAction = React.forwardRef< - HTMLButtonElement, - React.ComponentProps<"button"> & { - asChild?: boolean - showOnHover?: boolean - } ->(({ className, asChild = false, showOnHover = false, ...props }, ref) => { - const Comp = asChild ? Slot : "button" - - return ( - svg]:size-4 [&>svg]:shrink-0", - // Increases the hit area of the button on mobile. - "after:absolute after:-inset-2 after:md:hidden", - "peer-data-[size=sm]/menu-button:top-1", - "peer-data-[size=default]/menu-button:top-1.5", - "peer-data-[size=lg]/menu-button:top-2.5", - "group-data-[collapsible=icon]:hidden", - showOnHover && - "group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0", - className - )} - {...props} - /> - ) -}) -SidebarMenuAction.displayName = "SidebarMenuAction" - -const SidebarMenuBadge = React.forwardRef< - HTMLDivElement, - React.ComponentProps<"div"> ->(({ className, ...props }, ref) => ( -
    -)) -SidebarMenuBadge.displayName = "SidebarMenuBadge" - -const SidebarMenuSkeleton = React.forwardRef< - HTMLDivElement, - React.ComponentProps<"div"> & { - showIcon?: boolean - } ->(({ className, showIcon = false, ...props }, ref) => { - // Random width between 50 to 90%. - const width = React.useMemo(() => { - return "70%" - }, []) - - return ( -
    - {showIcon && ( - - )} - -
    - ) -}) -SidebarMenuSkeleton.displayName = "SidebarMenuSkeleton" - -const SidebarMenuSub = React.forwardRef< - HTMLUListElement, - React.ComponentProps<"ul"> ->(({ className, ...props }, ref) => ( -
      -)) -SidebarMenuSub.displayName = "SidebarMenuSub" - -const SidebarMenuSubItem = React.forwardRef< - HTMLLIElement, - React.ComponentProps<"li"> ->(({ ...props }, ref) =>
    • ) -SidebarMenuSubItem.displayName = "SidebarMenuSubItem" - -const SidebarMenuSubButton = React.forwardRef< - HTMLAnchorElement, - React.ComponentProps<"a"> & { - asChild?: boolean - size?: "sm" | "md" - isActive?: boolean - } ->(({ asChild = false, size = "md", isActive, className, ...props }, ref) => { - const Comp = asChild ? Slot : "a" - - return ( - span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground", - "data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground", - size === "sm" && "text-xs", - size === "md" && "text-sm", - "group-data-[collapsible=icon]:hidden", - className - )} - {...props} - /> - ) -}) -SidebarMenuSubButton.displayName = "SidebarMenuSubButton" - -export { - Sidebar, - SidebarContent, - SidebarFooter, - SidebarGroup, - SidebarGroupAction, - SidebarGroupContent, - SidebarGroupLabel, - SidebarHeader, - SidebarInput, - SidebarInset, - SidebarMenu, - SidebarMenuAction, - SidebarMenuBadge, - SidebarMenuButton, - SidebarMenuItem, - SidebarMenuSkeleton, - SidebarMenuSub, - SidebarMenuSubButton, - SidebarMenuSubItem, - SidebarProvider, - SidebarRail, - SidebarSeparator, - SidebarTrigger, - useSidebar, -} diff --git a/apps/desktop-ui/src/lib/tool-categories.ts b/apps/desktop-ui/src/lib/tool-categories.ts index c12ab4c1..d10f4569 100644 --- a/apps/desktop-ui/src/lib/tool-categories.ts +++ b/apps/desktop-ui/src/lib/tool-categories.ts @@ -5,6 +5,7 @@ export const toolCategoryMap: Record = { notes: 'Productivity', 'snippet-manager': 'Productivity', 'to-do': 'Productivity', + 'api-keys': 'Security', 'email-validator': 'Security', 'environment-manager': 'Security', 'jwt-decoder': 'Security', diff --git a/apps/desktop-ui/src/store/__tests__/tool-sidebar-store.test.ts b/apps/desktop-ui/src/store/__tests__/tool-sidebar-store.test.ts new file mode 100644 index 00000000..f4314ea2 --- /dev/null +++ b/apps/desktop-ui/src/store/__tests__/tool-sidebar-store.test.ts @@ -0,0 +1,36 @@ +/** + * Tests for tool-sidebar-store.ts — per-tool collapse state behind ToolSidebarLayout. + */ +import { useToolSidebarStore } from "../tool-sidebar-store" + +beforeEach(() => { + useToolSidebarStore.setState({ collapsed: {} }) +}) + +describe("tool-sidebar-store", () => { + it("treats an unseen tool as open", () => { + expect(useToolSidebarStore.getState().collapsed["notes"]).toBeUndefined() + }) + + it("collapses and reopens a tool", () => { + const { setCollapsed } = useToolSidebarStore.getState() + setCollapsed("notes", true) + expect(useToolSidebarStore.getState().collapsed["notes"]).toBe(true) + setCollapsed("notes", false) + expect(useToolSidebarStore.getState().collapsed["notes"]).toBe(false) + }) + + it("toggle flips from the open default and back", () => { + const { toggle } = useToolSidebarStore.getState() + toggle("bookmarks") + expect(useToolSidebarStore.getState().collapsed["bookmarks"]).toBe(true) + toggle("bookmarks") + expect(useToolSidebarStore.getState().collapsed["bookmarks"]).toBe(false) + }) + + it("keeps tools independent", () => { + const { setCollapsed } = useToolSidebarStore.getState() + setCollapsed("notes", true) + expect(useToolSidebarStore.getState().collapsed["s3-drive"]).toBeUndefined() + }) +}) diff --git a/apps/desktop-ui/src/store/tool-sidebar-store.ts b/apps/desktop-ui/src/store/tool-sidebar-store.ts new file mode 100644 index 00000000..5694945c --- /dev/null +++ b/apps/desktop-ui/src/store/tool-sidebar-store.ts @@ -0,0 +1,25 @@ +import { create } from 'zustand' +import { persist } from 'zustand/middleware' + +interface ToolSidebarState { + /** + * Desktop collapse state keyed by tool id. A missing key means "open" — the + * default — so a tool that has never been collapsed stores nothing. + */ + collapsed: Record + setCollapsed: (toolId: string, collapsed: boolean) => void + toggle: (toolId: string) => void +} + +export const useToolSidebarStore = create()( + persist( + (set) => ({ + collapsed: {}, + setCollapsed: (toolId, collapsed) => + set((s) => ({ collapsed: { ...s.collapsed, [toolId]: collapsed } })), + toggle: (toolId) => + set((s) => ({ collapsed: { ...s.collapsed, [toolId]: !s.collapsed[toolId] } })), + }), + { name: 'tool-sidebar-storage' }, + ), +) From 267729f7b5484640062681e54bf1543a5a2b8169 Mon Sep 17 00:00:00 2001 From: AKHIL Date: Sat, 15 Aug 2026 22:36:55 +0530 Subject: [PATCH 03/11] ui --- apps/desktop-ui/src/components/api-key-vault/api-key-list.tsx | 2 +- .../src/components/password-manager/password-list.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/desktop-ui/src/components/api-key-vault/api-key-list.tsx b/apps/desktop-ui/src/components/api-key-vault/api-key-list.tsx index 36a5f3b7..9867dbfd 100644 --- a/apps/desktop-ui/src/components/api-key-vault/api-key-list.tsx +++ b/apps/desktop-ui/src/components/api-key-vault/api-key-list.tsx @@ -207,7 +207,7 @@ export function ApiKeyList() { /> } > -
      +
      diff --git a/apps/desktop-ui/src/components/password-manager/password-list.tsx b/apps/desktop-ui/src/components/password-manager/password-list.tsx index 59cfca67..dabc28c3 100644 --- a/apps/desktop-ui/src/components/password-manager/password-list.tsx +++ b/apps/desktop-ui/src/components/password-manager/password-list.tsx @@ -380,7 +380,7 @@ export function PasswordList() { } >
      {/* Mobile Header */} {isMobile && ( From 3cac7ae01cb6a34271de4e4c06f81919d3e9cf91 Mon Sep 17 00:00:00 2001 From: AKHIL Date: Sat, 15 Aug 2026 22:53:50 +0530 Subject: [PATCH 04/11] Add threatcrush Security scan ignore file --- .github/workflows/release.yml | 6 +++++ .threatcrushignore | 22 +++++++++++++++++++ .../components/api-client/request-tabs.tsx | 1 + .../jwt-decoder/jwt-decoder-layout.tsx | 3 +++ .../password-manager/password-card.tsx | 1 + .../password-item-swipeable.tsx | 1 + .../password-manager/password-list.tsx | 1 + .../components/s3-drive/add-bucket-dialog.tsx | 1 + .../ssh-key-generator-layout.tsx | 3 +++ apps/desktop-ui/src/lib/curl-to-code.ts | 2 ++ .../src/lib/data-explorer/firestore-api.ts | 1 + apps/desktop-ui/src/lib/jwt-decode.ts | 2 ++ apps/desktop-ui/src/lib/nosql-dialects.ts | 1 + 13 files changed, 45 insertions(+) create mode 100644 .threatcrushignore diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 216cbe71..170f1c21 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -112,6 +112,7 @@ jobs: SIG="$(ls "$BUNDLE"/macos/*.app.tar.gz.sig)" # Pull tauri's generated latest.json from the private release. + # threatcrush-disable-next-line secret-generic-credential GH_TOKEN="$GITHUB_TOKEN" gh release download "$TAG" \ --repo "$GITHUB_REPOSITORY" --pattern latest.json --dir . --clobber @@ -127,9 +128,14 @@ jobs: ' # Create the public release (once) and upload all four assets. + # GH_TOKEN is assigned from an env var, not a literal — the three + # lines below are scanner false positives, not embedded tokens. + # threatcrush-disable-next-line secret-generic-credential if ! GH_TOKEN="$RELEASES_TOKEN" gh release view "$TAG" --repo "$PUBLIC_REPO" >/dev/null 2>&1; then + # threatcrush-disable-next-line secret-generic-credential GH_TOKEN="$RELEASES_TOKEN" gh release create "$TAG" --repo "$PUBLIC_REPO" \ --title "MyDevTools $TAG" --notes "MyDevTools desktop $TAG" fi + # threatcrush-disable-next-line secret-generic-credential GH_TOKEN="$RELEASES_TOKEN" gh release upload "$TAG" --repo "$PUBLIC_REPO" \ --clobber "$DMG" "$TARGZ" "$SIG" latest.json diff --git a/.threatcrushignore b/.threatcrushignore new file mode 100644 index 00000000..0ae38730 --- /dev/null +++ b/.threatcrushignore @@ -0,0 +1,22 @@ +# Paths ThreatCrush should not scan. Everything listed here is sample data, +# UI copy, or generated output — never a credential this repo actually holds. +# Real code stays scanned: for a one-off false positive in source, put a +# `threatcrush-disable-next-line ` comment above the line instead of +# excluding the whole file. + +# Locale catalogues (27 locales x 2 apps). These are the UI strings for the +# SSH-key, JWT, S3 and database tools: example PEM markers, sample connection +# strings and placeholder keys shown to the user. 41 findings, all copy. +apps/*/messages + +# Blog posts: long-form tutorial prose with illustrative credentials in code +# fences ("mongodb://admin:secret@localhost", a demo master key). +apps/*/src/lib/blog/posts.ts + +# Audit lab scripts: fake tokens injected into a throwaway browser session to +# measure egress. Nothing here authenticates against anything. +scripts/audit + +# Generated / local-only trees. Not in a CI checkout, noisy in a local run. +.claude/worktrees +.open-next diff --git a/apps/desktop-ui/src/components/api-client/request-tabs.tsx b/apps/desktop-ui/src/components/api-client/request-tabs.tsx index d257efa8..d12e2e4f 100644 --- a/apps/desktop-ui/src/components/api-client/request-tabs.tsx +++ b/apps/desktop-ui/src/components/api-client/request-tabs.tsx @@ -1189,6 +1189,7 @@ export function RequestTabs({