diff --git a/apps/desktop-ui/messages/af.json b/apps/desktop-ui/messages/af.json index cfdd5d36..aa61f875 100644 --- a/apps/desktop-ui/messages/af.json +++ b/apps/desktop-ui/messages/af.json @@ -5223,6 +5223,12 @@ "offline": "100% aflyn" }, "ToolSidebar": { + "expand": "Vou sybalk uit", + "collapse": "Vou sybalk in", + "resize": "Verstel sybalk se grootte", + "resetWidth": "Herstel sybalk se breedte", + "moreSections": "Meer afdelings", + "sections": "Sybalk-afdelings", "show": "Wys sybalk", "hide": "Versteek sybalk" }, diff --git a/apps/desktop-ui/messages/ar.json b/apps/desktop-ui/messages/ar.json index 94522005..4e1ac6d2 100644 --- a/apps/desktop-ui/messages/ar.json +++ b/apps/desktop-ui/messages/ar.json @@ -5223,6 +5223,12 @@ "offline": "100% دون اتصال" }, "ToolSidebar": { + "expand": "توسيع الشريط الجانبي", + "collapse": "طي الشريط الجانبي", + "resize": "تغيير حجم الشريط الجانبي", + "resetWidth": "إعادة تعيين عرض الشريط الجانبي", + "moreSections": "المزيد من الأقسام", + "sections": "أقسام الشريط الجانبي", "show": "إظهار الشريط الجانبي", "hide": "إخفاء الشريط الجانبي" }, diff --git a/apps/desktop-ui/messages/ca.json b/apps/desktop-ui/messages/ca.json index 5b980ce2..0bbbc2fb 100644 --- a/apps/desktop-ui/messages/ca.json +++ b/apps/desktop-ui/messages/ca.json @@ -5223,6 +5223,12 @@ "offline": "100% fora de línia" }, "ToolSidebar": { + "expand": "Amplia la barra lateral", + "collapse": "Redueix la barra lateral", + "resize": "Redimensiona la barra lateral", + "resetWidth": "Restableix l'amplada de la barra lateral", + "moreSections": "Més seccions", + "sections": "Seccions de la barra lateral", "show": "Mostra la barra lateral", "hide": "Amaga la barra lateral" }, diff --git a/apps/desktop-ui/messages/cs.json b/apps/desktop-ui/messages/cs.json index d2940606..ae84c5af 100644 --- a/apps/desktop-ui/messages/cs.json +++ b/apps/desktop-ui/messages/cs.json @@ -5223,6 +5223,12 @@ "offline": "100% offline" }, "ToolSidebar": { + "expand": "Rozbalit postranní panel", + "collapse": "Sbalit postranní panel", + "resize": "Změnit šířku panelu", + "resetWidth": "Obnovit šířku panelu", + "moreSections": "Další sekce", + "sections": "Sekce postranního panelu", "show": "Zobrazit postranní panel", "hide": "Skrýt postranní panel" }, diff --git a/apps/desktop-ui/messages/da.json b/apps/desktop-ui/messages/da.json index c92161b5..c348e15d 100644 --- a/apps/desktop-ui/messages/da.json +++ b/apps/desktop-ui/messages/da.json @@ -5223,6 +5223,12 @@ "offline": "100 % offline" }, "ToolSidebar": { + "expand": "Udvid sidepanel", + "collapse": "Skjul sidepanel", + "resize": "Tilpas sidepanelets bredde", + "resetWidth": "Nulstil sidepanelets bredde", + "moreSections": "Flere sektioner", + "sections": "Sidepanelsektioner", "show": "Vis sidepanel", "hide": "Skjul sidepanel" }, diff --git a/apps/desktop-ui/messages/de.json b/apps/desktop-ui/messages/de.json index ea508a90..b3af3829 100644 --- a/apps/desktop-ui/messages/de.json +++ b/apps/desktop-ui/messages/de.json @@ -5223,6 +5223,12 @@ "offline": "100 % offline" }, "ToolSidebar": { + "expand": "Seitenleiste ausklappen", + "collapse": "Seitenleiste einklappen", + "resize": "Seitenleiste anpassen", + "resetWidth": "Breite zurücksetzen", + "moreSections": "Weitere Abschnitte", + "sections": "Abschnitte der Seitenleiste", "show": "Seitenleiste einblenden", "hide": "Seitenleiste ausblenden" }, diff --git a/apps/desktop-ui/messages/el.json b/apps/desktop-ui/messages/el.json index a22c17ec..d441276c 100644 --- a/apps/desktop-ui/messages/el.json +++ b/apps/desktop-ui/messages/el.json @@ -5223,6 +5223,12 @@ "offline": "100% εκτός σύνδεσης" }, "ToolSidebar": { + "expand": "Ανάπτυξη πλαϊνής στήλης", + "collapse": "Σύμπτυξη πλαϊνής στήλης", + "resize": "Αλλαγή πλάτους", + "resetWidth": "Επαναφορά πλάτους", + "moreSections": "Περισσότερες ενότητες", + "sections": "Ενότητες πλαϊνής στήλης", "show": "Εμφάνιση πλαϊνής μπάρας", "hide": "Απόκρυψη πλαϊνής μπάρας" }, diff --git a/apps/desktop-ui/messages/en.json b/apps/desktop-ui/messages/en.json index f1f97a6d..a430419d 100644 --- a/apps/desktop-ui/messages/en.json +++ b/apps/desktop-ui/messages/en.json @@ -5310,6 +5310,12 @@ "offline": "100% offline" }, "ToolSidebar": { + "expand": "Expand sidebar", + "collapse": "Collapse sidebar", + "resize": "Resize sidebar", + "resetWidth": "Reset sidebar width", + "moreSections": "More sections", + "sections": "Sidebar sections", "show": "Show sidebar", "hide": "Hide sidebar" }, diff --git a/apps/desktop-ui/messages/es.json b/apps/desktop-ui/messages/es.json index 194b0446..b5bd3aa9 100644 --- a/apps/desktop-ui/messages/es.json +++ b/apps/desktop-ui/messages/es.json @@ -5223,6 +5223,12 @@ "offline": "100% sin conexión" }, "ToolSidebar": { + "expand": "Expandir barra lateral", + "collapse": "Contraer barra lateral", + "resize": "Redimensionar barra lateral", + "resetWidth": "Restablecer ancho", + "moreSections": "Más secciones", + "sections": "Secciones de la barra lateral", "show": "Mostrar barra lateral", "hide": "Ocultar barra lateral" }, diff --git a/apps/desktop-ui/messages/fa.json b/apps/desktop-ui/messages/fa.json index e01d8972..10b2a386 100644 --- a/apps/desktop-ui/messages/fa.json +++ b/apps/desktop-ui/messages/fa.json @@ -5223,6 +5223,12 @@ "offline": "۱۰۰٪ آفلاین" }, "ToolSidebar": { + "expand": "گسترش نوار کناری", + "collapse": "جمع کردن نوار کناری", + "resize": "تغییر اندازه نوار کناری", + "resetWidth": "بازنشانی عرض نوار کناری", + "moreSections": "بخش‌های بیشتر", + "sections": "بخش‌های نوار کناری", "show": "نمایش نوار کناری", "hide": "پنهان کردن نوار کناری" }, diff --git a/apps/desktop-ui/messages/fr.json b/apps/desktop-ui/messages/fr.json index 41af1a02..e4420437 100644 --- a/apps/desktop-ui/messages/fr.json +++ b/apps/desktop-ui/messages/fr.json @@ -5223,6 +5223,12 @@ "offline": "100 % hors ligne" }, "ToolSidebar": { + "expand": "Développer la barre latérale", + "collapse": "Réduire la barre latérale", + "resize": "Redimensionner la barre latérale", + "resetWidth": "Réinitialiser la largeur", + "moreSections": "Plus de sections", + "sections": "Sections de la barre latérale", "show": "Afficher la barre latérale", "hide": "Masquer la barre latérale" }, diff --git a/apps/desktop-ui/messages/id.json b/apps/desktop-ui/messages/id.json index 13ca9cdd..ba78f61d 100644 --- a/apps/desktop-ui/messages/id.json +++ b/apps/desktop-ui/messages/id.json @@ -5223,6 +5223,12 @@ "offline": "100% offline" }, "ToolSidebar": { + "expand": "Bentangkan bilah sisi", + "collapse": "Ciutkan bilah sisi", + "resize": "Ubah ukuran bilah sisi", + "resetWidth": "Atur ulang lebar bilah sisi", + "moreSections": "Bagian lainnya", + "sections": "Bagian bilah sisi", "show": "Tampilkan bilah sisi", "hide": "Sembunyikan bilah sisi" }, diff --git a/apps/desktop-ui/messages/it.json b/apps/desktop-ui/messages/it.json index 21110000..5cf8185b 100644 --- a/apps/desktop-ui/messages/it.json +++ b/apps/desktop-ui/messages/it.json @@ -5223,6 +5223,12 @@ "offline": "100% offline" }, "ToolSidebar": { + "expand": "Espandi barra laterale", + "collapse": "Comprimi barra laterale", + "resize": "Ridimensiona barra laterale", + "resetWidth": "Reimposta larghezza", + "moreSections": "Altre sezioni", + "sections": "Sezioni della barra laterale", "show": "Mostra barra laterale", "hide": "Nascondi barra laterale" }, diff --git a/apps/desktop-ui/messages/ja.json b/apps/desktop-ui/messages/ja.json index 378cb30a..2fe81b64 100644 --- a/apps/desktop-ui/messages/ja.json +++ b/apps/desktop-ui/messages/ja.json @@ -5223,6 +5223,12 @@ "offline": "完全オフライン" }, "ToolSidebar": { + "expand": "サイドバーを展開", + "collapse": "サイドバーを折りたたむ", + "resize": "サイドバーの幅を変更", + "resetWidth": "サイドバーの幅をリセット", + "moreSections": "その他のセクション", + "sections": "サイドバーのセクション", "show": "サイドバーを表示", "hide": "サイドバーを非表示" }, diff --git a/apps/desktop-ui/messages/ko.json b/apps/desktop-ui/messages/ko.json index 5e87a02c..69e705ba 100644 --- a/apps/desktop-ui/messages/ko.json +++ b/apps/desktop-ui/messages/ko.json @@ -5223,6 +5223,12 @@ "offline": "100% 오프라인" }, "ToolSidebar": { + "expand": "사이드바 펼치기", + "collapse": "사이드바 접기", + "resize": "사이드바 너비 조절", + "resetWidth": "사이드바 너비 초기화", + "moreSections": "섹션 더 보기", + "sections": "사이드바 섹션", "show": "사이드바 표시", "hide": "사이드바 숨기기" }, diff --git a/apps/desktop-ui/messages/ms.json b/apps/desktop-ui/messages/ms.json index de039994..86646f8c 100644 --- a/apps/desktop-ui/messages/ms.json +++ b/apps/desktop-ui/messages/ms.json @@ -5223,6 +5223,12 @@ "offline": "100% luar talian" }, "ToolSidebar": { + "expand": "Kembangkan bar sisi", + "collapse": "Runtuhkan bar sisi", + "resize": "Ubah saiz bar sisi", + "resetWidth": "Tetapkan semula lebar bar sisi", + "moreSections": "Lagi bahagian", + "sections": "Bahagian bar sisi", "show": "Tunjukkan bar sisi", "hide": "Sembunyikan bar sisi" }, diff --git a/apps/desktop-ui/messages/nb.json b/apps/desktop-ui/messages/nb.json index ded6f006..a958af60 100644 --- a/apps/desktop-ui/messages/nb.json +++ b/apps/desktop-ui/messages/nb.json @@ -5223,6 +5223,12 @@ "offline": "100 % offline" }, "ToolSidebar": { + "expand": "Utvid sidepanel", + "collapse": "Skjul sidepanel", + "resize": "Endre bredde på sidepanel", + "resetWidth": "Tilbakestill bredde", + "moreSections": "Flere seksjoner", + "sections": "Sidepanelseksjoner", "show": "Vis sidepanel", "hide": "Skjul sidepanel" }, diff --git a/apps/desktop-ui/messages/nl.json b/apps/desktop-ui/messages/nl.json index 25c096d0..b93207a8 100644 --- a/apps/desktop-ui/messages/nl.json +++ b/apps/desktop-ui/messages/nl.json @@ -5223,6 +5223,12 @@ "offline": "100% offline" }, "ToolSidebar": { + "expand": "Zijbalk uitklappen", + "collapse": "Zijbalk inklappen", + "resize": "Zijbalkbreedte aanpassen", + "resetWidth": "Breedte herstellen", + "moreSections": "Meer secties", + "sections": "Zijbalksecties", "show": "Zijbalk tonen", "hide": "Zijbalk verbergen" }, diff --git a/apps/desktop-ui/messages/pl.json b/apps/desktop-ui/messages/pl.json index 86cc6ab4..bdec4d87 100644 --- a/apps/desktop-ui/messages/pl.json +++ b/apps/desktop-ui/messages/pl.json @@ -5223,6 +5223,12 @@ "offline": "100% offline" }, "ToolSidebar": { + "expand": "Rozwiń panel boczny", + "collapse": "Zwiń panel boczny", + "resize": "Zmień szerokość panelu", + "resetWidth": "Przywróć szerokość panelu", + "moreSections": "Więcej sekcji", + "sections": "Sekcje panelu bocznego", "show": "Pokaż pasek boczny", "hide": "Ukryj pasek boczny" }, diff --git a/apps/desktop-ui/messages/pt-BR.json b/apps/desktop-ui/messages/pt-BR.json index c9957d75..d0afff29 100644 --- a/apps/desktop-ui/messages/pt-BR.json +++ b/apps/desktop-ui/messages/pt-BR.json @@ -5223,6 +5223,12 @@ "offline": "100% offline" }, "ToolSidebar": { + "expand": "Expandir barra lateral", + "collapse": "Recolher barra lateral", + "resize": "Redimensionar barra lateral", + "resetWidth": "Redefinir largura", + "moreSections": "Mais seções", + "sections": "Seções da barra lateral", "show": "Mostrar barra lateral", "hide": "Ocultar barra lateral" }, diff --git a/apps/desktop-ui/messages/pt.json b/apps/desktop-ui/messages/pt.json index 64be03dd..58a83485 100644 --- a/apps/desktop-ui/messages/pt.json +++ b/apps/desktop-ui/messages/pt.json @@ -5223,6 +5223,12 @@ "offline": "100% offline" }, "ToolSidebar": { + "expand": "Expandir barra lateral", + "collapse": "Recolher barra lateral", + "resize": "Redimensionar barra lateral", + "resetWidth": "Repor largura", + "moreSections": "Mais secções", + "sections": "Secções da barra lateral", "show": "Mostrar barra lateral", "hide": "Ocultar barra lateral" }, diff --git a/apps/desktop-ui/messages/ru.json b/apps/desktop-ui/messages/ru.json index f87079b6..d7459120 100644 --- a/apps/desktop-ui/messages/ru.json +++ b/apps/desktop-ui/messages/ru.json @@ -5223,6 +5223,12 @@ "offline": "100% офлайн" }, "ToolSidebar": { + "expand": "Развернуть боковую панель", + "collapse": "Свернуть боковую панель", + "resize": "Изменить ширину панели", + "resetWidth": "Сбросить ширину панели", + "moreSections": "Ещё разделы", + "sections": "Разделы боковой панели", "show": "Показать боковую панель", "hide": "Скрыть боковую панель" }, diff --git a/apps/desktop-ui/messages/sv.json b/apps/desktop-ui/messages/sv.json index fa8bc36a..1753ab49 100644 --- a/apps/desktop-ui/messages/sv.json +++ b/apps/desktop-ui/messages/sv.json @@ -5223,6 +5223,12 @@ "offline": "100 % offline" }, "ToolSidebar": { + "expand": "Expandera sidopanelen", + "collapse": "Fäll ihop sidopanelen", + "resize": "Ändra sidopanelens bredd", + "resetWidth": "Återställ bredden", + "moreSections": "Fler sektioner", + "sections": "Sidopanelens sektioner", "show": "Visa sidopanel", "hide": "Dölj sidopanel" }, diff --git a/apps/desktop-ui/messages/tr.json b/apps/desktop-ui/messages/tr.json index 858d5948..a4c0333b 100644 --- a/apps/desktop-ui/messages/tr.json +++ b/apps/desktop-ui/messages/tr.json @@ -5223,6 +5223,12 @@ "offline": "%100 çevrimdışı" }, "ToolSidebar": { + "expand": "Kenar çubuğunu genişlet", + "collapse": "Kenar çubuğunu daralt", + "resize": "Kenar çubuğunu yeniden boyutlandır", + "resetWidth": "Genişliği sıfırla", + "moreSections": "Daha fazla bölüm", + "sections": "Kenar çubuğu bölümleri", "show": "Kenar çubuğunu göster", "hide": "Kenar çubuğunu gizle" }, diff --git a/apps/desktop-ui/messages/uk.json b/apps/desktop-ui/messages/uk.json index e6ae9299..2820a96c 100644 --- a/apps/desktop-ui/messages/uk.json +++ b/apps/desktop-ui/messages/uk.json @@ -5223,6 +5223,12 @@ "offline": "100% офлайн" }, "ToolSidebar": { + "expand": "Розгорнути бічну панель", + "collapse": "Згорнути бічну панель", + "resize": "Змінити ширину панелі", + "resetWidth": "Скинути ширину панелі", + "moreSections": "Більше розділів", + "sections": "Розділи бічної панелі", "show": "Показати бічну панель", "hide": "Сховати бічну панель" }, diff --git a/apps/desktop-ui/messages/vi.json b/apps/desktop-ui/messages/vi.json index e2b80a14..344f364c 100644 --- a/apps/desktop-ui/messages/vi.json +++ b/apps/desktop-ui/messages/vi.json @@ -5223,6 +5223,12 @@ "offline": "100% ngoại tuyến" }, "ToolSidebar": { + "expand": "Mở rộng thanh bên", + "collapse": "Thu gọn thanh bên", + "resize": "Thay đổi kích thước thanh bên", + "resetWidth": "Đặt lại chiều rộng thanh bên", + "moreSections": "Thêm mục", + "sections": "Các mục thanh bên", "show": "Hiện thanh bên", "hide": "Ẩn thanh bên" }, diff --git a/apps/desktop-ui/messages/zh.json b/apps/desktop-ui/messages/zh.json index 333bb79d..461e2643 100644 --- a/apps/desktop-ui/messages/zh.json +++ b/apps/desktop-ui/messages/zh.json @@ -5223,6 +5223,12 @@ "offline": "100% 离线" }, "ToolSidebar": { + "expand": "展开侧边栏", + "collapse": "折叠侧边栏", + "resize": "调整侧边栏宽度", + "resetWidth": "重置侧边栏宽度", + "moreSections": "更多分区", + "sections": "侧边栏分区", "show": "显示侧边栏", "hide": "隐藏侧边栏" }, diff --git a/apps/desktop-ui/scripts/add-tool-sidebar-i18n.mjs b/apps/desktop-ui/scripts/add-tool-sidebar-i18n.mjs new file mode 100644 index 00000000..24ed9cf2 --- /dev/null +++ b/apps/desktop-ui/scripts/add-tool-sidebar-i18n.mjs @@ -0,0 +1,48 @@ +// Adds the collapse-to-icon rail keys to the ToolSidebar namespace in every +// locale. Idempotent: existing keys are left untouched. +import { readFileSync, writeFileSync, readdirSync } from 'node:fs' +import { join } from 'node:path' + +const DIR = new URL('../messages/', import.meta.url).pathname + +const T = { + en: { expand: 'Expand sidebar', collapse: 'Collapse sidebar', resize: 'Resize sidebar', resetWidth: 'Reset sidebar width', moreSections: 'More sections', sections: 'Sidebar sections' }, + af: { expand: 'Vou sybalk uit', collapse: 'Vou sybalk in', resize: 'Verstel sybalk se grootte', resetWidth: 'Herstel sybalk se breedte', moreSections: 'Meer afdelings', sections: 'Sybalk-afdelings' }, + ar: { expand: 'توسيع الشريط الجانبي', collapse: 'طي الشريط الجانبي', resize: 'تغيير حجم الشريط الجانبي', resetWidth: 'إعادة تعيين عرض الشريط الجانبي', moreSections: 'المزيد من الأقسام', sections: 'أقسام الشريط الجانبي' }, + ca: { expand: 'Amplia la barra lateral', collapse: 'Redueix la barra lateral', resize: 'Redimensiona la barra lateral', resetWidth: "Restableix l'amplada de la barra lateral", moreSections: 'Més seccions', sections: 'Seccions de la barra lateral' }, + cs: { expand: 'Rozbalit postranní panel', collapse: 'Sbalit postranní panel', resize: 'Změnit šířku panelu', resetWidth: 'Obnovit šířku panelu', moreSections: 'Další sekce', sections: 'Sekce postranního panelu' }, + da: { expand: 'Udvid sidepanel', collapse: 'Skjul sidepanel', resize: 'Tilpas sidepanelets bredde', resetWidth: 'Nulstil sidepanelets bredde', moreSections: 'Flere sektioner', sections: 'Sidepanelsektioner' }, + de: { expand: 'Seitenleiste ausklappen', collapse: 'Seitenleiste einklappen', resize: 'Seitenleiste anpassen', resetWidth: 'Breite zurücksetzen', moreSections: 'Weitere Abschnitte', sections: 'Abschnitte der Seitenleiste' }, + el: { expand: 'Ανάπτυξη πλαϊνής στήλης', collapse: 'Σύμπτυξη πλαϊνής στήλης', resize: 'Αλλαγή πλάτους', resetWidth: 'Επαναφορά πλάτους', moreSections: 'Περισσότερες ενότητες', sections: 'Ενότητες πλαϊνής στήλης' }, + es: { expand: 'Expandir barra lateral', collapse: 'Contraer barra lateral', resize: 'Redimensionar barra lateral', resetWidth: 'Restablecer ancho', moreSections: 'Más secciones', sections: 'Secciones de la barra lateral' }, + fa: { expand: 'گسترش نوار کناری', collapse: 'جمع کردن نوار کناری', resize: 'تغییر اندازه نوار کناری', resetWidth: 'بازنشانی عرض نوار کناری', moreSections: 'بخش‌های بیشتر', sections: 'بخش‌های نوار کناری' }, + fr: { expand: 'Développer la barre latérale', collapse: 'Réduire la barre latérale', resize: 'Redimensionner la barre latérale', resetWidth: 'Réinitialiser la largeur', moreSections: 'Plus de sections', sections: 'Sections de la barre latérale' }, + id: { expand: 'Bentangkan bilah sisi', collapse: 'Ciutkan bilah sisi', resize: 'Ubah ukuran bilah sisi', resetWidth: 'Atur ulang lebar bilah sisi', moreSections: 'Bagian lainnya', sections: 'Bagian bilah sisi' }, + it: { expand: 'Espandi barra laterale', collapse: 'Comprimi barra laterale', resize: 'Ridimensiona barra laterale', resetWidth: 'Reimposta larghezza', moreSections: 'Altre sezioni', sections: 'Sezioni della barra laterale' }, + ja: { expand: 'サイドバーを展開', collapse: 'サイドバーを折りたたむ', resize: 'サイドバーの幅を変更', resetWidth: 'サイドバーの幅をリセット', moreSections: 'その他のセクション', sections: 'サイドバーのセクション' }, + ko: { expand: '사이드바 펼치기', collapse: '사이드바 접기', resize: '사이드바 너비 조절', resetWidth: '사이드바 너비 초기화', moreSections: '섹션 더 보기', sections: '사이드바 섹션' }, + ms: { expand: 'Kembangkan bar sisi', collapse: 'Runtuhkan bar sisi', resize: 'Ubah saiz bar sisi', resetWidth: 'Tetapkan semula lebar bar sisi', moreSections: 'Lagi bahagian', sections: 'Bahagian bar sisi' }, + nb: { expand: 'Utvid sidepanel', collapse: 'Skjul sidepanel', resize: 'Endre bredde på sidepanel', resetWidth: 'Tilbakestill bredde', moreSections: 'Flere seksjoner', sections: 'Sidepanelseksjoner' }, + nl: { expand: 'Zijbalk uitklappen', collapse: 'Zijbalk inklappen', resize: 'Zijbalkbreedte aanpassen', resetWidth: 'Breedte herstellen', moreSections: 'Meer secties', sections: 'Zijbalksecties' }, + pl: { expand: 'Rozwiń panel boczny', collapse: 'Zwiń panel boczny', resize: 'Zmień szerokość panelu', resetWidth: 'Przywróć szerokość panelu', moreSections: 'Więcej sekcji', sections: 'Sekcje panelu bocznego' }, + 'pt-BR': { expand: 'Expandir barra lateral', collapse: 'Recolher barra lateral', resize: 'Redimensionar barra lateral', resetWidth: 'Redefinir largura', moreSections: 'Mais seções', sections: 'Seções da barra lateral' }, + pt: { expand: 'Expandir barra lateral', collapse: 'Recolher barra lateral', resize: 'Redimensionar barra lateral', resetWidth: 'Repor largura', moreSections: 'Mais secções', sections: 'Secções da barra lateral' }, + ru: { expand: 'Развернуть боковую панель', collapse: 'Свернуть боковую панель', resize: 'Изменить ширину панели', resetWidth: 'Сбросить ширину панели', moreSections: 'Ещё разделы', sections: 'Разделы боковой панели' }, + sv: { expand: 'Expandera sidopanelen', collapse: 'Fäll ihop sidopanelen', resize: 'Ändra sidopanelens bredd', resetWidth: 'Återställ bredden', moreSections: 'Fler sektioner', sections: 'Sidopanelens sektioner' }, + tr: { expand: 'Kenar çubuğunu genişlet', collapse: 'Kenar çubuğunu daralt', resize: 'Kenar çubuğunu yeniden boyutlandır', resetWidth: 'Genişliği sıfırla', moreSections: 'Daha fazla bölüm', sections: 'Kenar çubuğu bölümleri' }, + uk: { expand: 'Розгорнути бічну панель', collapse: 'Згорнути бічну панель', resize: 'Змінити ширину панелі', resetWidth: 'Скинути ширину панелі', moreSections: 'Більше розділів', sections: 'Розділи бічної панелі' }, + vi: { expand: 'Mở rộng thanh bên', collapse: 'Thu gọn thanh bên', resize: 'Thay đổi kích thước thanh bên', resetWidth: 'Đặt lại chiều rộng thanh bên', moreSections: 'Thêm mục', sections: 'Các mục thanh bên' }, + zh: { expand: '展开侧边栏', collapse: '折叠侧边栏', resize: '调整侧边栏宽度', resetWidth: '重置侧边栏宽度', moreSections: '更多分区', sections: '侧边栏分区' }, +} + +let changed = 0 +for (const file of readdirSync(DIR).filter((f) => f.endsWith('.json'))) { + const locale = file.replace(/\.json$/, '') + const add = T[locale] ?? T.en + const path = join(DIR, file) + const json = JSON.parse(readFileSync(path, 'utf8')) + json.ToolSidebar = { ...add, ...(json.ToolSidebar ?? {}) } + writeFileSync(path, JSON.stringify(json, null, 2) + '\n') + changed++ +} +console.log(`updated ${changed} locale files`) 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 94cc182a..cfb8220b 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 @@ -5,6 +5,9 @@ import NotesSidebar from "@/components/notes/NotesSidebar"; import { ToolSidebarLayout } from "@/components/tools/tool-sidebar"; import { NotebookPen } from "lucide-react"; import { useTranslations } from "next-intl"; +import { useVaultGuard } from "@/hooks/use-vault-guard"; +import { VaultLockedPlaceholder } from "@/components/vault-locked-placeholder"; +import { VaultRestoringSkeleton } from "@/components/vault-restoring-skeleton"; function NotesLayout({ children }: { children: React.ReactNode }) { const tSidebar = useTranslations("Notes.sidebar"); @@ -33,6 +36,20 @@ function NotesLayout({ children }: { children: React.ReactNode }) { } export default function NotesClientLayout({ children }: { children: React.ReactNode }) { + const { isUnlocked, isRestoring } = useVaultGuard(); + + // Gate here, not in page.tsx. The page is this layout's *children*, so a gate + // there swaps the editor for the placeholder while the sidebar column keeps + // rendering — a note tree, a search field and a New note button, all backed + // by a vault that cannot be read. password-manager, api-keys and + // environment-manager gate before their ToolSidebarLayout for the same + // reason; notes only differed because its layout is a separate wrapper. + // + // Above NotesProvider, so a locked vault does not mount the provider and + // have it fail to load notes it has no key for. + if (isRestoring) return ; + if (!isUnlocked) return ; + return ( {children} diff --git a/apps/desktop-ui/src/app/dashboard/page.tsx b/apps/desktop-ui/src/app/dashboard/page.tsx index 6df8dc43..ceec1f1b 100644 --- a/apps/desktop-ui/src/app/dashboard/page.tsx +++ b/apps/desktop-ui/src/app/dashboard/page.tsx @@ -262,7 +262,6 @@ const DashboardPage: React.FC = () => {
{/* ── Mobile Sticky Header (outside padded container for full-bleed) ── */} { {/* ── Desktop Hero (inside padded container for alignment) ── */} void @@ -144,6 +144,31 @@ export function CollectionsSidebar({ onLoadRequest: loadRequest }: CollectionsSi return filteredCollections.filter((c) => c.workspace === activeWorkspaceId) }, [filteredCollections, activeWorkspaceId]) + // Collections stay reachable from the collapsed rail. A collection has no + // open/closed state of its own — only the folders inside it toggle — so + // picking one expands the panel and scrolls that collection into view. + useToolSidebarRail( + "collections", + React.useMemo( + () => + collectionsForActiveWs.slice(0, 8).map((c) => ({ + id: c.id, + label: c.name, + icon: FolderGit2, + count: c.items.length, + onSelect: () => { + // The panel is expanding in the same tick; wait for layout. + requestAnimationFrame(() => + document + .querySelector(`[data-collection-id="${CSS.escape(c.id)}"]`) + ?.scrollIntoView({ block: "nearest" }), + ) + }, + })), + [collectionsForActiveWs], + ), + ) + const activeWorkspaceName = workspaces.find((w) => w.id === activeWorkspaceId)?.name ?? "All" const [selectedCollections, setSelectedCollections] = React.useState>(new Set()) const [deleteBulkDialogOpen, setDeleteBulkDialogOpen] = React.useState(false) @@ -364,7 +389,7 @@ export function CollectionsSidebar({ onLoadRequest: loadRequest }: CollectionsSi
) : ( collectionsForActiveWs.map((collection) => ( -
+
diff --git a/apps/desktop-ui/src/components/bookmarks/folder-tree.tsx b/apps/desktop-ui/src/components/bookmarks/folder-tree.tsx index 4858c11e..5b0fe0c2 100644 --- a/apps/desktop-ui/src/components/bookmarks/folder-tree.tsx +++ b/apps/desktop-ui/src/components/bookmarks/folder-tree.tsx @@ -1,6 +1,6 @@ "use client" -import { useState } from "react" +import { useMemo, useState } from "react" import { motion, AnimatePresence } from "framer-motion" import { IconFolder, @@ -43,6 +43,7 @@ import { import EditFolderDialog from "./edit-folder-dialog" import AddFolderDialog from "./add-folder-dialog" import { useTranslations } from "next-intl" +import { useToolSidebarRail } from "@/components/tools/tool-sidebar" interface FolderTreeProps { onSelectFolder: (id: string | null) => void @@ -51,9 +52,37 @@ interface FolderTreeProps { export default function FolderTree({ onSelectFolder }: FolderTreeProps) { const t = useTranslations("Bookmarks.folderTree") const { selectedFolderId, bookmarks } = useBookmarkStore() + // Memoized selector (bookmark-store.ts:506), so calling it here as well as + // in RootFolders costs nothing. + const rootFolders = useChildFolders(null) const totalBookmarks = bookmarks.length + // "All bookmarks" plus the root folders, for the collapsed rail. + useToolSidebarRail( + "folders", + useMemo( + () => [ + { + id: "all", + label: t("allBookmarks"), + icon: IconBookmarks, + count: totalBookmarks, + active: selectedFolderId === null, + onSelect: () => onSelectFolder(null), + }, + ...rootFolders.slice(0, 7).map(f => ({ + id: f.id, + label: f.name, + icon: IconFolder, + active: selectedFolderId === f.id, + onSelect: () => onSelectFolder(f.id), + })), + ], + [rootFolders, totalBookmarks, selectedFolderId, onSelectFolder, t], + ), + ) + return (
{/* All Bookmarks */} diff --git a/apps/desktop-ui/src/components/dashboard/__tests__/dashboard-greeting.test.ts b/apps/desktop-ui/src/components/dashboard/__tests__/dashboard-greeting.test.ts new file mode 100644 index 00000000..5789da6b --- /dev/null +++ b/apps/desktop-ui/src/components/dashboard/__tests__/dashboard-greeting.test.ts @@ -0,0 +1,27 @@ +/** + * The dashboard greeting takes its name from the local profile (user + * preferences), never from `useAuth` — that identity is a fixed object whose + * displayName is always null by design (utils/useAuth.tsx:29). + */ +import { greetingFirstName } from "../types" + +describe("greetingFirstName", () => { + it("uses the first word of a full name", () => { + expect(greetingFirstName("Akhil Edathadan")).toBe("Akhil") + }) + + it("passes a single-word name through", () => { + expect(greetingFirstName("Akhil")).toBe("Akhil") + }) + + it("ignores surrounding and repeated whitespace", () => { + expect(greetingFirstName(" Akhil Edathadan ")).toBe("Akhil") + }) + + it("returns null for an unset name so the greeting falls back", () => { + expect(greetingFirstName("")).toBeNull() + expect(greetingFirstName(" ")).toBeNull() + expect(greetingFirstName(undefined)).toBeNull() + expect(greetingFirstName(null)).toBeNull() + }) +}) diff --git a/apps/desktop-ui/src/components/dashboard/dashboard-hero.tsx b/apps/desktop-ui/src/components/dashboard/dashboard-hero.tsx index befde774..ed2b3884 100644 --- a/apps/desktop-ui/src/components/dashboard/dashboard-hero.tsx +++ b/apps/desktop-ui/src/components/dashboard/dashboard-hero.tsx @@ -4,12 +4,12 @@ import React from 'react' import Link from 'next/link' import { Layers, Zap, Pin, Clock, History } from 'lucide-react' import { useTranslations } from 'next-intl' -import { dashboardGreeting } from './types' +import { dashboardGreeting, greetingFirstName } from './types' +import { useAppUser } from '@/hooks/use-app-user' import { useCountUp } from '@/hooks/use-count-up' import { cn } from '@/lib/utils' interface DashboardHeroProps { - user: { displayName?: string | null } | null totalTools: number pinnedCount: number recentCount: number @@ -67,7 +67,6 @@ function Stat({ } export function DashboardHero({ - user, totalTools, pinnedCount, recentCount, @@ -75,6 +74,10 @@ export function DashboardHero({ desktopOnly, }: DashboardHeroProps) { const t = useTranslations('Dashboard') + // The editable profile name lives in local preferences. `useAuth` is identity + // only — its displayName is a hardcoded null, so greeting off it never named + // anyone. + const firstName = greetingFirstName(useAppUser().name) return ( <> @@ -92,9 +95,7 @@ export function DashboardHero({

{dashboardGreeting(t)} - {user?.displayName - ? t('commaName', { name: user.displayName.split(' ')[0] }) - : ''} + {firstName ? t('commaName', { name: firstName }) : ''}

@@ -124,9 +125,7 @@ export function DashboardHero({ {dashboardGreeting(t)}

- {user?.displayName - ? t('welcomeBackNamed', { name: user.displayName.split(' ')[0] }) - : t('welcomeBack')} + {firstName ? t('welcomeBackNamed', { name: firstName }) : t('welcomeBack')}

{t('tagline')}

diff --git a/apps/desktop-ui/src/components/dashboard/types.ts b/apps/desktop-ui/src/components/dashboard/types.ts index ca746129..368debef 100644 --- a/apps/desktop-ui/src/components/dashboard/types.ts +++ b/apps/desktop-ui/src/components/dashboard/types.ts @@ -103,6 +103,16 @@ export function groupDisplayTitle(title: string, t: (key: string) => string): st return title } +/** + * First name for the dashboard greeting, or null when the profile has no name + * set. The name comes from local preferences (`useAppUser`) — `useAuth` is + * identity only and its displayName is always null. + */ +export function greetingFirstName(name: string | null | undefined): string | null { + const first = name?.trim().split(/\s+/)[0] + return first || null +} + /** Time-of-day greeting key. */ export function dashboardGreeting(t: (key: string) => string): string { const hour = new Date().getHours() diff --git a/apps/desktop-ui/src/components/data-explorer/unified-sidebar.tsx b/apps/desktop-ui/src/components/data-explorer/unified-sidebar.tsx index 24b9b083..bff91586 100644 --- a/apps/desktop-ui/src/components/data-explorer/unified-sidebar.tsx +++ b/apps/desktop-ui/src/components/data-explorer/unified-sidebar.tsx @@ -7,6 +7,7 @@ import { motion, AnimatePresence } from "framer-motion"; import { IconAlertTriangle, IconChevronRight, + IconDatabase, IconDots, IconDownload, IconEdit, @@ -35,7 +36,7 @@ import { AlertDialogTitle, } from "@/components/ui/alert-dialog"; import { cn } from "@/lib/utils"; -import { useToolSidebarPanel } from "@/components/tools/tool-sidebar"; +import { useToolSidebarPanel, useToolSidebarRail } from "@/components/tools/tool-sidebar"; import { SOURCES, SOURCE_ORDER, getAdapter } from "./sources"; import { deleteConnection } from "./connection-service"; import type { OpenTabRequest, SourceId, UnifiedConnection } from "./types"; @@ -208,6 +209,31 @@ export function UnifiedSidebar({ [onOpenTab, panel] ); + // Connections stay reachable from the collapsed rail; picking one expands + // its tree, which is what the row click does too. + useToolSidebarRail( + "connections", + useMemo( + () => + connections + .filter((c): c is UnifiedConnection => !!c && typeof c.id === "string") + .slice(0, 8) + .map((c) => ({ + id: c.id, + label: c.name, + icon: IconDatabase, + active: expandedIds.has(c.id), + onSelect: () => + setExpandedIds((prev) => { + const next = new Set(prev); + next.add(c.id); + return next; + }), + })), + [connections, expandedIds] + ) + ); + const filtered = useMemo(() => { const q = query.trim().toLowerCase(); const safe = connections.filter( diff --git a/apps/desktop-ui/src/components/notes/NotesSidebar.tsx b/apps/desktop-ui/src/components/notes/NotesSidebar.tsx index 25fc902f..87983a3b 100644 --- a/apps/desktop-ui/src/components/notes/NotesSidebar.tsx +++ b/apps/desktop-ui/src/components/notes/NotesSidebar.tsx @@ -55,7 +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 { ToolSidebarActions, useToolSidebarPanel, useToolSidebarRail } 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"; @@ -273,7 +273,7 @@ NoteItem.displayName = "NoteItem"; export default function NotesSidebar() { const t = useTranslations("Notes.sidebar"); const { notes, noteById, isLoading, searchIndexReady } = useNotesData(); - const { activeNoteId } = useNotesUI(); + const { activeNoteId, setActiveNoteId } = useNotesUI(); const { createNote, deleteNote, moveNote, warmSearchIndex } = useNotesActions(); const [noteToDelete, setNoteToDelete] = useState(null); const [noteToMove, setNoteToMove] = useState(null); @@ -309,6 +309,23 @@ export default function NotesSidebar() { const pinnedNotes = useMemo(() => rootNotes.filter(n => n.pinned), [rootNotes]); const unpinnedNotes = useMemo(() => rootNotes.filter(n => !n.pinned), [rootNotes]); + // Pinned notes are the sidebar's own shortlist, so they are what the + // collapsed rail offers. + useToolSidebarRail( + "pinned", + useMemo( + () => + pinnedNotes.slice(0, 8).map(n => ({ + id: n.id, + label: n.title || t("untitled"), + icon: Pin, + active: n.id === activeNoteId, + onSelect: () => setActiveNoteId(n.id), + })), + [pinnedNotes, activeNoteId, setActiveNoteId, t], + ), + ); + const searchResults = useMemo(() => { const trimmed = debouncedQuery.trim(); if (!trimmed) return 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 2fe076b5..78933c87 100644 --- a/apps/desktop-ui/src/components/password-manager/password-list.tsx +++ b/apps/desktop-ui/src/components/password-manager/password-list.tsx @@ -367,6 +367,7 @@ export function PasswordList() { value={quickFilter} onChange={(id) => setQuickFilter(id as typeof quickFilter)} heading={t("filters.heading")} + railGroupId="security" /> {tagFilters.length > 0 && ( )}
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 11c78fa7..34d2fdd2 100644 --- a/apps/desktop-ui/src/components/s3-drive/bucket-sidebar.tsx +++ b/apps/desktop-ui/src/components/s3-drive/bucket-sidebar.tsx @@ -1,6 +1,6 @@ "use client" -import { useState } from "react" +import { useMemo, useState } from "react" import { toast } from "sonner" import { cn } from "@/lib/utils" import { Button } from "@/components/ui/button" @@ -23,7 +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 { useToolSidebarPanel, useToolSidebarRail } from "@/components/tools/tool-sidebar" import { AddBucketDialog } from "./add-bucket-dialog" type Props = { @@ -61,6 +61,23 @@ function connSubtitle(conn: DecryptedConnection): string { export function BucketSidebar({ encryptionKey }: Props) { const { connections, activeConnectionId, setActiveConnection, removeConnection } = useS3DriveStore() const panel = useToolSidebarPanel() + + // Connections stay switchable from the collapsed rail. + useToolSidebarRail( + "connections", + useMemo( + () => + connections.slice(0, 8).map((c) => ({ + id: c.id, + label: c.name, + icon: IconBucket, + active: c.id === activeConnectionId, + onSelect: () => setActiveConnection(c.id), + })), + [connections, activeConnectionId, setActiveConnection], + ), + ) + const [addOpen, setAddOpen] = useState(false) const [editTarget, setEditTarget] = useState(null) const [deleteTarget, setDeleteTarget] = useState(null) diff --git a/apps/desktop-ui/src/components/secure-files/folder-tree.tsx b/apps/desktop-ui/src/components/secure-files/folder-tree.tsx index 6cb9b185..63a42b2f 100644 --- a/apps/desktop-ui/src/components/secure-files/folder-tree.tsx +++ b/apps/desktop-ui/src/components/secure-files/folder-tree.tsx @@ -11,7 +11,7 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu" -import { useToolSidebarPanel } from "@/components/tools/tool-sidebar" +import { useToolSidebarPanel, useToolSidebarRail } from "@/components/tools/tool-sidebar" import { cn } from "@/lib/utils" import type { FolderNode } from "@/lib/secure-files" @@ -55,11 +55,30 @@ export function FolderTree({ root, currentDir, ...actions }: { root: FolderNode; const select = React.useCallback( (path: string) => { actions.onSelect(path) - panel?.close() + // Only when the panel floats over the result. On desktop it is a pinned + // column, and closing it collapsed the sidebar on every folder pick. + if (panel?.isOverlay) panel.close() }, [actions, panel], ) + // Top-level folders stay reachable from the collapsed rail. + useToolSidebarRail( + "folders", + React.useMemo( + () => + root.children.slice(0, 8).map((child) => ({ + id: child.path, + label: child.name, + icon: IconFolder, + count: child.files.length, + active: currentDir === child.path, + onSelect: () => select(child.path), + })), + [root.children, currentDir, select], + ), + ) + return (
- +
) : (

{t("chooseFolderBody")}

diff --git a/apps/desktop-ui/src/components/sidebar/client-layout.tsx b/apps/desktop-ui/src/components/sidebar/client-layout.tsx index 7b345565..697bc66e 100644 --- a/apps/desktop-ui/src/components/sidebar/client-layout.tsx +++ b/apps/desktop-ui/src/components/sidebar/client-layout.tsx @@ -100,16 +100,8 @@ function Layout({ children }: { children: React.ReactNode }) { } export function ClientLayout({ children }: { children: React.ReactNode }) { - // Hydrate the workspace store on app launch. Nothing else does this on - // desktop (EnsureBackendSession is unmounted here), and without it the - // local personal workspace never resolves — encrypted tools (api-key-vault, - // password-manager, environment-manager) fail with "No active workspace". - useEffect(() => { - const { hydrated, loadFromBackend } = useWorkspaceStore.getState(); - if (!hydrated) { - loadFromBackend().catch((e) => console.warn('Workspace hydration failed:', e)); - } - }, []); - + // Workspace hydration lives in (mounted in app/layout.tsx). + // Doing it here too fired a second GET + POST /workspaces/active on every + // desktop launch, and 404'd in a plain browser dev server (no Next API routes). return {children}; } 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 aa5f964f..248707f1 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,7 +68,7 @@ import { SheetTitle, SheetTrigger, } from "@/components/ui/sheet"; -import { ToolSidebarActions, ToolSidebarLayout } from "@/components/tools/tool-sidebar"; +import { ToolSidebarActions, ToolSidebarLayout, useToolSidebarRail } from "@/components/tools/tool-sidebar"; import { cn } from "@/lib/utils"; import { SnippetMonaco, @@ -319,6 +319,27 @@ export function SnippetManagerTool() { const pinnedVisible = visibleSnippets.filter((s) => s.pinned); const unpinnedVisible = visibleSnippets.filter((s) => !s.pinned); + // Pinned snippets are the sidebar's own shortlist, so they are what the + // collapsed rail offers. Flushing the pending save mirrors the row click — + // selecting from the rail must not drop an unsaved edit. + useToolSidebarRail( + "pinned", + useMemo( + () => + pinnedVisible.slice(0, 8).map((s) => ({ + id: s.id, + label: s.title, + icon: IconCode, + active: s.id === selectedId, + onSelect: () => { + debouncedSaveCode.flush(); + setSelectedId(s.id); + }, + })), + [pinnedVisible, selectedId, debouncedSaveCode] + ) + ); + const effectiveLanguage = useMemo( () => resolveEditorLanguage(draftLanguage, debouncedCode), [draftLanguage, debouncedCode] diff --git a/apps/desktop-ui/src/components/tools/__tests__/tool-sidebar-rail.memo.test.tsx b/apps/desktop-ui/src/components/tools/__tests__/tool-sidebar-rail.memo.test.tsx new file mode 100644 index 00000000..75d75300 --- /dev/null +++ b/apps/desktop-ui/src/components/tools/__tests__/tool-sidebar-rail.memo.test.tsx @@ -0,0 +1,42 @@ +/** + * Metadata-level test: this project's Jest runs in the node environment with no + * DOM harness, so we assert the memo contract rather than rendered output — + * same approach as api-client/__tests__/collection-item.memo.test.tsx. + */ +jest.mock("next-intl", () => ({ useTranslations: () => (k: string) => k })) + +describe("RailButton memo", () => { + it("is wrapped in React.memo with a custom comparator", () => { + const { RailButton } = require("../tool-sidebar-rail") + expect(RailButton.$$typeof).toBe(Symbol.for("react.memo")) + expect(RailButton.compare).toBeInstanceOf(Function) + }) + + it("skips re-render when the displayed props are unchanged", () => { + const { RailButton } = require("../tool-sidebar-rail") + const compare = RailButton.compare as (a: never, b: never) => boolean + const base = { + entry: { id: "tags", label: "Tags", count: 3, active: false }, + accent: { bg: "bg-blue-500/10", text: "text-blue-500" }, + onActivate: () => {}, + } + const same = { ...base, entry: { ...base.entry }, onActivate: () => {} } + expect(compare(base as never, same as never)).toBe(true) + }) + + it("re-renders when active or count changes", () => { + const { RailButton } = require("../tool-sidebar-rail") + const compare = RailButton.compare as (a: never, b: never) => boolean + const base = { + entry: { id: "tags", label: "Tags", count: 3, active: false }, + accent: { bg: "bg-blue-500/10", text: "text-blue-500" }, + onActivate: () => {}, + } + expect( + compare(base as never, { ...base, entry: { ...base.entry, active: true } } as never), + ).toBe(false) + expect(compare(base as never, { ...base, entry: { ...base.entry, count: 4 } } as never)).toBe( + false, + ) + }) +}) diff --git a/apps/desktop-ui/src/components/tools/tool-sidebar-primitives.tsx b/apps/desktop-ui/src/components/tools/tool-sidebar-primitives.tsx new file mode 100644 index 00000000..a34eafc0 --- /dev/null +++ b/apps/desktop-ui/src/components/tools/tool-sidebar-primitives.tsx @@ -0,0 +1,196 @@ +'use client' + +import * as React from 'react' +import { AnimatePresence, motion } from 'framer-motion' +import { IconChevronRight } from '@tabler/icons-react' +import { Search, X } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { useToolSidebarPanel } from '@/components/tools/tool-sidebar' +import { cn } from '@/lib/utils' + +/** + * Body building blocks shared by tool sidebars. Adopting them is per-tool + * follow-up work — nothing was rewritten onto them in the branch that added + * them, so they exist here unused until a tool migrates. + */ + +/** The filter field five sidebars each rebuild. */ +export function ToolSidebarSearch({ + value, + onChange, + placeholder, + className, +}: { + value: string + onChange: (next: string) => void + placeholder: string + className?: string +}) { + return ( +
+ + onChange(e.target.value)} + placeholder={placeholder} + aria-label={placeholder} + className="h-8 pl-8 pr-8 text-sm" + /> + {value && ( + + )} +
+ ) +} + +/** + * Collapsible group following the project convention: chevron rotates 90deg + * when open, framer-motion height/opacity at ~0.2s, nothing rendered when empty. + */ +export function ToolSidebarSection({ + title, + count, + defaultOpen = true, + actions, + children, +}: { + title: string + count?: number + defaultOpen?: boolean + actions?: React.ReactNode + children: React.ReactNode +}) { + const [open, setOpen] = React.useState(defaultOpen) + if (count === 0) return null + + return ( +
+
+ + {actions} +
+ + {open && ( + + {children} + + )} + +
+ ) +} + +interface ToolSidebarRowProps { + label: string + icon?: React.ElementType + count?: number + active?: boolean + /** Indent depth for tree rows. */ + level?: number + actions?: React.ReactNode + onSelect: () => void +} + +function ToolSidebarRowImpl({ + label, + icon: Icon, + count, + active, + level = 0, + actions, + onSelect, +}: ToolSidebarRowProps) { + const panel = useToolSidebarPanel() + return ( +
+ + {actions && ( +
+ {actions} +
+ )} +
+ ) +} + +export const ToolSidebarRow = React.memo(ToolSidebarRowImpl, (a, b) => { + return ( + a.label === b.label && + a.icon === b.icon && + a.count === b.count && + a.active === b.active && + a.level === b.level && + a.actions === b.actions && + a.onSelect === b.onSelect + ) +}) +ToolSidebarRow.displayName = 'ToolSidebarRow' + +export function ToolSidebarEmpty({ + icon: Icon, + message, + action, +}: { + icon: React.ElementType + message: string + action?: React.ReactNode +}) { + return ( +
+ +

{message}

+ {action} +
+ ) +} diff --git a/apps/desktop-ui/src/components/tools/tool-sidebar-rail.tsx b/apps/desktop-ui/src/components/tools/tool-sidebar-rail.tsx new file mode 100644 index 00000000..487ef00c --- /dev/null +++ b/apps/desktop-ui/src/components/tools/tool-sidebar-rail.tsx @@ -0,0 +1,296 @@ +'use client' + +import * as React from 'react' +import { MoreHorizontal, PanelLeft } from 'lucide-react' +import { useTranslations } from 'next-intl' +import { Button } from '@/components/ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip' +import { + RAIL_MAX_VISIBLE, + splitRailEntries, + type ToolSidebarRailEntry, +} from '@/lib/tool-sidebar-rail' +import { cn } from '@/lib/utils' + +/** + * The collapsed sidebar: a 48px column that keeps the tool's identity, its + * primary action and its facets reachable without expanding. Buttons are 40x40 + * inside 4px gutters, which clears the 44px pointer target. + * + * The rail owns no state. Entries arrive already flattened, and every click is + * routed back through `onActivate` so the layout can expand the panel first. + */ + +interface Accent { + bg: string + text: string +} + +interface RailButtonProps { + entry: ToolSidebarRailEntry + accent: Accent + onActivate: (entry: ToolSidebarRailEntry) => void +} + +function RailButtonImpl({ entry, accent, onActivate }: RailButtonProps) { + const Icon = entry.icon + return ( + + + + + + {entry.count !== undefined ? `${entry.label} (${entry.count})` : entry.label} + + + ) +} + +/** + * Memoized on displayed data only. `onActivate` is compared by identity but the + * layout passes a `useCallback`-stable handler, and entry handlers are resolved + * through a ref at click time, so a changing closure never forces a re-render. + */ +export const RailButton = React.memo(RailButtonImpl, (a, b) => { + return ( + a.entry.id === b.entry.id && + a.entry.label === b.entry.label && + a.entry.count === b.entry.count && + a.entry.active === b.entry.active && + a.entry.icon === b.entry.icon && + a.entry.groupStart === b.entry.groupStart && + a.accent.bg === b.accent.bg && + a.accent.text === b.accent.text + ) +}) +RailButton.displayName = 'RailButton' + +export interface ToolSidebarRailProps { + /** Tool identity chip at the top. Clicking it expands the panel. */ + icon: React.ElementType + title: string + accent: Accent + entries: ToolSidebarRailEntry[] + primaryAction?: { icon: React.ElementType; label: string; onClick: () => void } + /** Expand the panel (rail bottom button, tool chip, and before any entry). */ + onExpand: () => void + onActivate: (entry: ToolSidebarRailEntry) => void + /** Wired by the layout so `aria-controls` points at the panel. */ + panelId: string + /** So the layout can return focus here when the panel collapses. */ + expandRef?: React.RefObject +} + +export function ToolSidebarRail({ + icon: Icon, + title, + accent, + entries, + primaryAction, + onExpand, + onActivate, + panelId, + expandRef, +}: ToolSidebarRailProps) { + const t = useTranslations('ToolSidebar') + const listRef = React.useRef(null) + const { visible, overflow } = splitRailEntries(entries, RAIL_MAX_VISIBLE) + + // Roving tabindex: the container is the single tab stop, arrows move focus + // between buttons. Without this a tool with eight facets adds eight stops to + // every Tab sweep of the app. + const focusAt = React.useCallback((delta: number | 'first' | 'last') => { + const nodes = Array.from( + listRef.current?.querySelectorAll('button[data-rail-entry]') ?? [], + ) + if (!nodes.length) return + const current = nodes.findIndex((n) => n === document.activeElement) + let next: number + if (delta === 'first') next = 0 + else if (delta === 'last') next = nodes.length - 1 + else next = current === -1 ? 0 : (current + delta + nodes.length) % nodes.length + nodes[next]?.focus() + }, []) + + const onKeyDown = React.useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'ArrowDown') { + e.preventDefault() + focusAt(1) + } else if (e.key === 'ArrowUp') { + e.preventDefault() + focusAt(-1) + } else if (e.key === 'Home') { + e.preventDefault() + focusAt('first') + } else if (e.key === 'End') { + e.preventDefault() + focusAt('last') + } + }, + [focusAt], + ) + + return ( + +
+ + + + + + {title} + + + + {primaryAction && ( + + + + + + {primaryAction.label} + + + )} + + {(visible.length > 0 || overflow.length > 0) && ( +
+ )} + +
+ {visible.map((entry) => ( + + {entry.groupStart && ( +
+ )} + + + ))} + + {overflow.length > 0 && ( + + + + + + {overflow.map((entry) => ( + onActivate(entry)}> + {entry.icon && } + {entry.label} + {entry.count !== undefined && ( + + {entry.count} + + )} + + ))} + + + )} +
+ + + + + + + {t('expand')} + + +
+ + ) +} diff --git a/apps/desktop-ui/src/components/tools/tool-sidebar.tsx b/apps/desktop-ui/src/components/tools/tool-sidebar.tsx index cb7d6e35..d1f91db8 100644 --- a/apps/desktop-ui/src/components/tools/tool-sidebar.tsx +++ b/apps/desktop-ui/src/components/tools/tool-sidebar.tsx @@ -13,8 +13,36 @@ 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 { ToolSidebarRail } from '@/components/tools/tool-sidebar-rail' +import { + DEFAULT_SIDEBAR_WIDTH, + MAX_SIDEBAR_WIDTH, + MIN_SIDEBAR_WIDTH, + emptyRailRegistry, + flattenRail, + isSidebarShortcut, + railEntriesKey, + railHandlerKey, + registerRailGroup, + unregisterRailGroup, + type RailRegistry, + type ToolSidebarRailEntry, +} from '@/lib/tool-sidebar-rail' import { cn } from '@/lib/utils' +export type { ToolSidebarRailEntry } from '@/lib/tool-sidebar-rail' + +// Body primitives live in their own file (this one is the layout), but consumers +// import everything sidebar-shaped from here. `ToolSidebarFilterList` stays +// below: the primitives file imports `useToolSidebarPanel` from here, so moving +// it would close an import cycle. +export { + ToolSidebarSearch, + ToolSidebarSection, + ToolSidebarRow, + ToolSidebarEmpty, +} from '@/components/tools/tool-sidebar-primitives' + /** * The one in-tool left panel. Every app-like tool (notes, bookmarks, api-client, * data-explorer, s3-drive, snippet-manager, password-manager, api-keys, @@ -33,6 +61,12 @@ interface ToolSidebarContextValue { * left hidden behind the panel. */ isOverlay: boolean + /** + * False while the panel is collapsed or the mobile sheet is shut. The body + * stays mounted in that state — gate anything expensive that assumes the user + * can see the panel on this rather than on mount. + */ + isVisible: 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 @@ -60,6 +94,50 @@ export function ToolSidebarActions({ children }: { children: React.ReactNode }) return slot ? createPortal(children, slot) : null } +interface RailRegistrationValue { + register: (groupId: string, entries: ToolSidebarRailEntry[]) => void + unregister: (groupId: string) => void + /** + * Live handlers keyed `${groupId}/${entryId}`. Handlers live in a ref rather + * than in registry state so a body that rebuilds its closures every render + * does not re-register in a loop, and the rail still calls the current one. + */ + handlers: React.RefObject void>> +} + +const RailRegistrationContext = React.createContext(null) + +/** + * Publishes rail entries from inside the panel body, so the collapsed 48px rail + * can offer the tool's facets/roots instead of only an expand button. Safe to + * call unconditionally: outside a ToolSidebarLayout it is a no-op. + * + * `entries` may be rebuilt every render — registration is keyed on the entries' + * displayed data, not on array identity. + */ +export function useToolSidebarRail(groupId: string, entries: ToolSidebarRailEntry[]) { + const reg = React.useContext(RailRegistrationContext) + const key = railEntriesKey(entries) + const latest = React.useRef(entries) + latest.current = entries + + // Refresh handlers on every commit so the rail always calls the current + // closure, without that identity churn triggering re-registration. + React.useEffect(() => { + if (!reg) return + const map = reg.handlers.current + for (const e of latest.current) { + if (e.onSelect) map.set(railHandlerKey(groupId, e.id), e.onSelect) + } + }) + + React.useEffect(() => { + if (!reg) return + reg.register(groupId, latest.current) + return () => reg.unregister(groupId) + }, [reg, groupId, key]) +} + export interface ToolSidebarFilterItem { id: string label: string @@ -79,6 +157,7 @@ export function ToolSidebarFilterList({ onChange, heading, className, + railGroupId, }: { items: ToolSidebarFilterItem[] value: string @@ -86,8 +165,33 @@ export function ToolSidebarFilterList({ /** Optional uppercase section label above the rows. */ heading?: string className?: string + /** + * Group id for the collapsed rail. Defaults to the heading, so a panel with + * two filter lists (password-manager: security + tags) produces two rail + * groups. Pass explicitly when there is no heading. + */ + railGroupId?: string }) { const panel = useToolSidebarPanel() + const groupId = railGroupId ?? heading ?? 'filters' + + // The facets this list already renders are exactly what the collapsed rail + // should offer, so publish them rather than making each tool restate them. + const railEntries = React.useMemo( + () => + items.map((item) => ({ + id: item.id, + label: item.label, + icon: item.icon, + count: item.count, + active: item.id === value, + onSelect: () => onChange(item.id), + })), + [items, value, onChange], + ) + + useToolSidebarRail(groupId, railEntries) + return (
{heading && ( @@ -138,6 +242,13 @@ interface ToolSidebarLayoutProps { title: string /** Header buttons (add, sort, filter…) rendered left of the collapse toggle. */ actions?: React.ReactNode + /** + * Rail entries the page owns directly. Bodies that own their own state should + * call `useToolSidebarRail` instead; these render after the registered ones. + */ + rail?: ToolSidebarRailEntry[] + /** The tool's one "create" affordance. Shown in the header and in the rail. */ + primaryAction?: { icon: React.ElementType; label: string; onClick: () => void } /** * 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 @@ -153,6 +264,8 @@ export function ToolSidebarLayout({ icon: Icon, title, actions, + rail, + primaryAction, sidebar, children, className, @@ -161,27 +274,138 @@ export function ToolSidebarLayout({ const isMobile = useIsMobile() const collapsed = useToolSidebarStore((s) => !!s.collapsed[toolId]) const setCollapsed = useToolSidebarStore((s) => s.setCollapsed) + const width = useToolSidebarStore((s) => s.width[toolId] ?? DEFAULT_SIDEBAR_WIDTH) + const setWidth = useToolSidebarStore((s) => s.setWidth) + const resetWidth = useToolSidebarStore((s) => s.resetWidth) + const dragOriginRef = React.useRef<{ x: number; width: number } | null>(null) const [sheetOpen, setSheetOpen] = React.useState(false) const [actionsSlot, setActionsSlot] = React.useState(null) + const [registry, setRegistry] = React.useState(emptyRailRegistry) + const handlers = React.useRef void>>(new Map()) + const panelId = React.useId() + const railExpandRef = React.useRef(null) + const panelRef = React.useRef(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] ?? '') // The panel floats only as the mobile sheet; on desktop it is a pinned column. const isOverlay = isMobile && sheetOpen + const hidden = isMobile || collapsed + + const railRegistration = React.useMemo( + () => ({ + register: (groupId, entries) => setRegistry((r) => registerRailGroup(r, groupId, entries)), + unregister: (groupId) => setRegistry((r) => unregisterRailGroup(r, groupId)), + handlers, + }), + [], + ) + + const railEntries = React.useMemo(() => { + const flat = flattenRail(registry) + if (!rail?.length) return flat + // Prop entries are their own group, so they get a separator too. + return [...flat, ...rail.map((e, i) => ({ ...e, groupStart: i === 0 && flat.length > 0 }))] + }, [registry, rail]) + + const expand = React.useCallback(() => { + if (isMobile) setSheetOpen(true) + else setCollapsed(toolId, false) + }, [isMobile, setCollapsed, toolId]) const close = React.useCallback(() => { if (sheetOpen) setSheetOpen(false) else if (!isMobile) setCollapsed(toolId, true) }, [isMobile, setCollapsed, toolId, sheetOpen]) + const activateRailEntry = React.useCallback( + (entry: ToolSidebarRailEntry) => { + expand() + // `flattenRail` namespaced the id to `groupId/entryId`, which is exactly + // the handler-map key. Entries that arrived via the `rail` prop are not in + // the map, so they fall back to their own closure. + ;(handlers.current.get(entry.id) ?? entry.onSelect)?.() + }, + [expand], + ) + const ctx = React.useMemo( - () => ({ isMobile, isOverlay, close }), - [isMobile, isOverlay, close], + () => ({ isMobile, isOverlay, isVisible: !hidden, close }), + [isMobile, isOverlay, hidden, close], + ) + + // Cmd/Ctrl+\ toggles the panel. Registered per mounted layout; inactive tool + // tabs stay mounted behind display:none, so the handler bails unless its own + // tab is the one on screen (offsetParent is null inside a hidden ancestor). + React.useEffect(() => { + if (isMobile) return + const onKeyDown = (e: KeyboardEvent) => { + if (!isSidebarShortcut(e as unknown as Parameters[0])) return + if (!panelRef.current?.offsetParent && !railExpandRef.current?.offsetParent) return + e.preventDefault() + if (collapsed) { + setCollapsed(toolId, false) + requestAnimationFrame(() => panelRef.current?.focus()) + } else { + setCollapsed(toolId, true) + requestAnimationFrame(() => railExpandRef.current?.focus()) + } + } + window.addEventListener('keydown', onKeyDown) + return () => window.removeEventListener('keydown', onKeyDown) + }, [collapsed, isMobile, setCollapsed, toolId]) + + const onHandlePointerDown = React.useCallback( + (e: React.PointerEvent) => { + e.preventDefault() + e.currentTarget.setPointerCapture(e.pointerId) + dragOriginRef.current = { x: e.clientX, width } + // Dragging across the content pane would otherwise select its text. + document.body.style.userSelect = 'none' + }, + [width], + ) + + const onHandlePointerMove = React.useCallback( + (e: React.PointerEvent) => { + const origin = dragOriginRef.current + if (!origin) return + setWidth(toolId, origin.width + (e.clientX - origin.x)) + }, + [setWidth, toolId], + ) + + const onHandlePointerUp = React.useCallback((e: React.PointerEvent) => { + dragOriginRef.current = null + e.currentTarget.releasePointerCapture(e.pointerId) + document.body.style.userSelect = '' + }, []) + + const onHandleKeyDown = React.useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'ArrowLeft') { + e.preventDefault() + setWidth(toolId, width - 16) + } else if (e.key === 'ArrowRight') { + e.preventDefault() + setWidth(toolId, width + 16) + } else if (e.key === 'Home') { + e.preventDefault() + resetWidth(toolId) + } + }, + [setWidth, resetWidth, toolId, width], ) const panel = ( -
+
{title}
+ {primaryAction && ( + + )} {actions}
{!isMobile && ( @@ -203,9 +439,15 @@ export function ToolSidebarLayout({ variant="ghost" size="icon" className="h-8 w-8 cursor-pointer" - onClick={() => setCollapsed(toolId, true)} - aria-label={t('hide')} - title={t('hide')} + onClick={() => { + setCollapsed(toolId, true) + // Focus would otherwise land on when this button hides. + requestAnimationFrame(() => railExpandRef.current?.focus()) + }} + aria-label={t('collapse')} + title={t('collapse')} + aria-expanded={true} + aria-controls={panelId} > @@ -218,72 +460,84 @@ export function ToolSidebarLayout({
) - 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. - - One control, one job. The rail carries the tool's accent icon so the - collapsed strip still says which tool you are in, and swaps it for - the expand arrow on hover/focus — showing both at once read as two - competing buttons for the same action. */} - {hidden && ( -
- + {panel} +
resetWidth(toolId)} + onKeyDown={onHandleKeyDown} + className="absolute inset-y-0 -right-0.5 z-10 w-1 cursor-col-resize touch-none bg-transparent transition-colors hover:bg-primary/40 focus-visible:bg-primary/60 focus-visible:outline-none" + /> +
+ )} + + {isMobile && ( + + + + {title} + + {panel} + + + )} + + {/* Collapsed state is a 48px 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. The rail keeps + the tool's identity, its primary action and its facets reachable. */} + {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}
- )} - - {/* 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/lib/__tests__/secure-files.test.ts b/apps/desktop-ui/src/lib/__tests__/secure-files.test.ts index 08757bce..a5e023b0 100644 --- a/apps/desktop-ui/src/lib/__tests__/secure-files.test.ts +++ b/apps/desktop-ui/src/lib/__tests__/secure-files.test.ts @@ -90,3 +90,13 @@ describe("looksLikeText", () => { expect(looksLikeText(new Uint8Array())).toBe(true) }) }) + +describe("NO_FOLDER", () => { + it("is a value no real folder path can equal", () => { + const { NO_FOLDER } = require("../secure-files") + expect(typeof NO_FOLDER).toBe("string") + // "" is the root folder and must stay distinct from "no selection". + expect(NO_FOLDER).not.toBe("") + expect(NO_FOLDER).not.toMatch(/^[\w./-]+$/) + }) +}) diff --git a/apps/desktop-ui/src/lib/__tests__/tool-sidebar-rail.test.ts b/apps/desktop-ui/src/lib/__tests__/tool-sidebar-rail.test.ts new file mode 100644 index 00000000..a0c7066c --- /dev/null +++ b/apps/desktop-ui/src/lib/__tests__/tool-sidebar-rail.test.ts @@ -0,0 +1,198 @@ +/** + * Pure logic behind the collapsed tool-sidebar rail. Kept out of the React + * component because this project's Jest runs in the node environment with no + * DOM harness. + */ +import { + DEFAULT_SIDEBAR_WIDTH, + RAIL_MAX_VISIBLE, + clampSidebarWidth, + emptyRailRegistry, + flattenRail, + isSidebarShortcut, + railEntriesKey, + registerRailGroup, + splitRailEntries, + unregisterRailGroup, + type ToolSidebarRailEntry, +} from "../tool-sidebar-rail" + +const entry = (id: string, over: Partial = {}): ToolSidebarRailEntry => ({ + id, + label: id.toUpperCase(), + ...over, +}) + +describe("rail registry", () => { + it("keeps groups in registration order", () => { + let reg = emptyRailRegistry() + reg = registerRailGroup(reg, "security", [entry("weak")]) + reg = registerRailGroup(reg, "tags", [entry("work")]) + expect(reg.order).toEqual(["security", "tags"]) + }) + + it("re-registering a group replaces its entries without reordering", () => { + let reg = emptyRailRegistry() + reg = registerRailGroup(reg, "a", [entry("one")]) + reg = registerRailGroup(reg, "b", [entry("two")]) + reg = registerRailGroup(reg, "a", [entry("three")]) + expect(reg.order).toEqual(["a", "b"]) + expect(reg.groups.a.map((e) => e.id)).toEqual(["three"]) + }) + + it("unregistering drops the group and its slot in the order", () => { + let reg = emptyRailRegistry() + reg = registerRailGroup(reg, "a", [entry("one")]) + reg = registerRailGroup(reg, "b", [entry("two")]) + reg = unregisterRailGroup(reg, "a") + expect(reg.order).toEqual(["b"]) + expect(reg.groups.a).toBeUndefined() + }) + + it("unregistering an unknown group returns the same object", () => { + const reg = registerRailGroup(emptyRailRegistry(), "a", [entry("one")]) + expect(unregisterRailGroup(reg, "nope")).toBe(reg) + }) + + it("flattens groups in order and marks where each new group starts", () => { + let reg = emptyRailRegistry() + reg = registerRailGroup(reg, "a", [entry("one"), entry("two")]) + reg = registerRailGroup(reg, "b", [entry("three")]) + const flat = flattenRail(reg) + expect(flat.map((e) => e.label)).toEqual(["ONE", "TWO", "THREE"]) + // The first entry overall does not start a group — nothing precedes it to + // separate from, and a leading separator would draw a stray line. + expect(flat.map((e) => !!e.groupStart)).toEqual([false, false, true]) + }) + + it("skips empty groups so no stray separator appears", () => { + let reg = emptyRailRegistry() + reg = registerRailGroup(reg, "a", [entry("one")]) + reg = registerRailGroup(reg, "empty", []) + reg = registerRailGroup(reg, "b", [entry("two")]) + const flat = flattenRail(reg) + expect(flat.map((e) => e.label)).toEqual(["ONE", "TWO"]) + expect(flat.map((e) => !!e.groupStart)).toEqual([false, true]) + }) + + it("namespaces ids by group so two groups can reuse an entry id", () => { + let reg = emptyRailRegistry() + reg = registerRailGroup(reg, "a", [entry("all")]) + reg = registerRailGroup(reg, "b", [entry("all")]) + const flat = flattenRail(reg) + expect(new Set(flat.map((e) => e.id)).size).toBe(2) + }) +}) + +describe("splitRailEntries", () => { + it("shows everything when the count fits", () => { + const entries = Array.from({ length: RAIL_MAX_VISIBLE }, (_, i) => entry(`e${i}`)) + const { visible, overflow } = splitRailEntries(entries, RAIL_MAX_VISIBLE) + expect(visible).toHaveLength(RAIL_MAX_VISIBLE) + expect(overflow).toHaveLength(0) + }) + + it("reserves the last slot for the overflow button when it does not fit", () => { + const entries = Array.from({ length: RAIL_MAX_VISIBLE + 1 }, (_, i) => entry(`e${i}`)) + const { visible, overflow } = splitRailEntries(entries, RAIL_MAX_VISIBLE) + expect(visible).toHaveLength(RAIL_MAX_VISIBLE - 1) + expect(overflow).toHaveLength(2) + expect(overflow.map((e) => e.id)).toEqual([`e${RAIL_MAX_VISIBLE - 1}`, `e${RAIL_MAX_VISIBLE}`]) + }) + + it("keeps an active entry visible by swapping it with the last visible slot", () => { + const entries = Array.from({ length: RAIL_MAX_VISIBLE + 3 }, (_, i) => + entry(`e${i}`, { active: i === RAIL_MAX_VISIBLE + 2 }), + ) + const { visible, overflow } = splitRailEntries(entries, RAIL_MAX_VISIBLE) + expect(visible.some((e) => e.active)).toBe(true) + expect(overflow.some((e) => e.active)).toBe(false) + }) +}) + +describe("railEntriesKey", () => { + it("is stable across a rebuilt array with equal contents", () => { + const a = [entry("one", { count: 3 }), entry("two")] + const b = [entry("one", { count: 3 }), entry("two")] + expect(railEntriesKey(a)).toBe(railEntriesKey(b)) + }) + + it("changes when a count changes", () => { + expect(railEntriesKey([entry("one", { count: 3 })])).not.toBe( + railEntriesKey([entry("one", { count: 4 })]), + ) + }) + + it("changes when the active entry changes", () => { + expect(railEntriesKey([entry("one", { active: true })])).not.toBe( + railEntriesKey([entry("one")]), + ) + }) + + it("ignores handler identity so a fresh closure does not re-register", () => { + expect(railEntriesKey([entry("one", { onSelect: () => {} })])).toBe( + railEntriesKey([entry("one", { onSelect: () => {} })]), + ) + }) +}) + +describe("isSidebarShortcut", () => { + const ev = (over: Partial[0]> = {}) => ({ + code: "Backslash", + metaKey: true, + ctrlKey: false, + target: null, + ...over, + }) + + it("accepts cmd-backslash", () => { + expect(isSidebarShortcut(ev())).toBe(true) + }) + + it("accepts ctrl-backslash", () => { + expect(isSidebarShortcut(ev({ metaKey: false, ctrlKey: true }))).toBe(true) + }) + + it("rejects backslash with no modifier, so typing a backslash still works", () => { + expect(isSidebarShortcut(ev({ metaKey: false, ctrlKey: false }))).toBe(false) + }) + + it("rejects another key with the modifier held", () => { + expect(isSidebarShortcut(ev({ code: "KeyB" }))).toBe(false) + }) + + it("rejects the shortcut while focus is in a text field", () => { + expect(isSidebarShortcut(ev({ target: { tagName: "INPUT" } }))).toBe(false) + expect(isSidebarShortcut(ev({ target: { tagName: "TEXTAREA" } }))).toBe(false) + expect(isSidebarShortcut(ev({ target: { tagName: "DIV", isContentEditable: true } }))).toBe( + false, + ) + }) + + it("accepts the shortcut when focus is on a plain element", () => { + expect(isSidebarShortcut(ev({ target: { tagName: "DIV" } }))).toBe(true) + }) +}) + +describe("clampSidebarWidth", () => { + it("clamps below the minimum", () => { + expect(clampSidebarWidth(199)).toBe(200) + }) + + it("passes the boundaries through", () => { + expect(clampSidebarWidth(200)).toBe(200) + expect(clampSidebarWidth(480)).toBe(480) + }) + + it("clamps above the maximum", () => { + expect(clampSidebarWidth(481)).toBe(480) + }) + + it("falls back to the default for a non-finite width", () => { + expect(clampSidebarWidth(Number.NaN)).toBe(DEFAULT_SIDEBAR_WIDTH) + }) + + it("rounds fractional drag positions", () => { + expect(clampSidebarWidth(300.6)).toBe(301) + }) +}) diff --git a/apps/desktop-ui/src/lib/secure-files.ts b/apps/desktop-ui/src/lib/secure-files.ts index 24a0f399..b8578b4c 100644 --- a/apps/desktop-ui/src/lib/secure-files.ts +++ b/apps/desktop-ui/src/lib/secure-files.ts @@ -34,6 +34,15 @@ export function baseName(dir: string): string { return dir.slice(dir.lastIndexOf("/") + 1) } +/** + * `currentDir` value meaning "the overview is showing, no folder is selected". + * `""` is the root folder and would highlight it (folder-tree.tsx:70), so the + * sentinel has to be a string no path can equal. Written as an escape, not a + * raw byte — a literal NUL in the source makes the whole file binary to grep, + * ripgrep and diff, which then skip it silently. + */ +export const NO_FOLDER = "\u0000" + /** Build the folder tree from file dirs plus any empty (not yet populated) dirs. */ export function buildFolderTree(files: SecureFileEntry[], extraDirs: Iterable = []): FolderNode { const root: FolderNode = { name: "", path: "", children: [], files: [] } diff --git a/apps/desktop-ui/src/lib/tool-sidebar-rail.ts b/apps/desktop-ui/src/lib/tool-sidebar-rail.ts new file mode 100644 index 00000000..f43e9f72 --- /dev/null +++ b/apps/desktop-ui/src/lib/tool-sidebar-rail.ts @@ -0,0 +1,150 @@ +/** + * Pure logic behind the collapsed tool-sidebar rail — registry bookkeeping, + * overflow splitting, the keyboard shortcut predicate and the width clamp. + * + * This lives outside the React component on purpose: Jest here runs in the node + * environment with no DOM harness, so anything branchy has to be testable as a + * plain function. + */ +import type React from "react" + +export const DEFAULT_SIDEBAR_WIDTH = 256 +export const MIN_SIDEBAR_WIDTH = 200 +export const MAX_SIDEBAR_WIDTH = 480 + +/** Rail buttons that fit before the overflow menu takes the last slot. */ +export const RAIL_MAX_VISIBLE = 8 + +export interface ToolSidebarRailEntry { + /** Unique within its group. `flattenRail` namespaces it to `groupId/id`. */ + id: string + /** Tooltip text and accessible name. */ + label: string + icon?: React.ElementType + count?: number + active?: boolean + /** + * Set by `flattenRail` on the first entry of every group but the first, so + * the rail can draw a separator above it. Not set by producers. + */ + groupStart?: boolean + /** + * Invoked on rail click, after the layout expands the panel. Deliberately + * excluded from `railEntriesKey` — see the note there. + */ + onSelect?: () => void +} + +export interface RailRegistry { + /** Group ids in mount order. */ + order: string[] + groups: Record +} + +/** The handler-map key for an entry, shared by the registry and the layout. */ +export function railHandlerKey(groupId: string, entryId: string): string { + return `${groupId}/${entryId}` +} + +export function emptyRailRegistry(): RailRegistry { + return { order: [], groups: {} } +} + +export function registerRailGroup( + reg: RailRegistry, + groupId: string, + entries: ToolSidebarRailEntry[], +): RailRegistry { + return { + order: reg.order.includes(groupId) ? reg.order : [...reg.order, groupId], + groups: { ...reg.groups, [groupId]: entries }, + } +} + +export function unregisterRailGroup(reg: RailRegistry, groupId: string): RailRegistry { + if (!(groupId in reg.groups)) return reg + const groups = { ...reg.groups } + delete groups[groupId] + return { order: reg.order.filter((id) => id !== groupId), groups } +} + +/** + * Render order for the rail. Ids are namespaced to their group so two groups can + * both have an "all" entry, and the first entry of each group after the first is + * marked `groupStart` so the rail can draw a separator above it. Empty groups + * are skipped, so a tool whose facets are still loading leaves no stray line. + */ +export function flattenRail(reg: RailRegistry): ToolSidebarRailEntry[] { + const out: ToolSidebarRailEntry[] = [] + for (const groupId of reg.order) { + const entries = reg.groups[groupId] + if (!entries?.length) continue + entries.forEach((e, i) => { + out.push({ + ...e, + id: railHandlerKey(groupId, e.id), + groupStart: i === 0 && out.length > 0, + }) + }) + } + return out +} + +/** + * Splits entries into the buttons the rail draws and the ones behind the "…" + * menu. When anything overflows, the menu itself consumes a slot, so only + * `max - 1` entries stay visible. An active entry is always kept visible — + * a rail that hides the current selection tells you nothing. + */ +export function splitRailEntries( + entries: ToolSidebarRailEntry[], + max: number = RAIL_MAX_VISIBLE, +): { visible: ToolSidebarRailEntry[]; overflow: ToolSidebarRailEntry[] } { + if (entries.length <= max) return { visible: entries, overflow: [] } + + const visible = entries.slice(0, max - 1) + const overflow = entries.slice(max - 1) + + const activeIndex = overflow.findIndex((e) => e.active) + if (activeIndex !== -1 && visible.length) { + const [active] = overflow.splice(activeIndex, 1) + const displaced = visible[visible.length - 1] + visible[visible.length - 1] = active + overflow.unshift(displaced) + } + + return { visible, overflow } +} + +/** + * Dependency key for the registration effect. Handlers are excluded on purpose: + * a body that rebuilds `onSelect` every render would otherwise re-register in a + * loop. The layout reads live handlers through a ref instead, so excluding them + * here cannot go stale. + */ +export function railEntriesKey(entries: ToolSidebarRailEntry[]): string { + return entries.map((e) => `${e.id}${e.label}${e.count ?? ""}${e.active ? 1 : 0}`).join("") +} + +/** + * True when a keydown should toggle the tool sidebar. Matched on `code` rather + * than `key` because backslash sits behind different physical keys per layout. + */ +export function isSidebarShortcut(e: { + code: string + metaKey: boolean + ctrlKey: boolean + target: { tagName?: string; isContentEditable?: boolean } | null +}): boolean { + if (e.code !== "Backslash") return false + if (!e.metaKey && !e.ctrlKey) return false + const t = e.target + if (!t) return true + if (t.isContentEditable) return false + return t.tagName !== "INPUT" && t.tagName !== "TEXTAREA" && t.tagName !== "SELECT" +} + +export function clampSidebarWidth(px: number): number { + if (!Number.isFinite(px)) return DEFAULT_SIDEBAR_WIDTH + return Math.min(MAX_SIDEBAR_WIDTH, Math.max(MIN_SIDEBAR_WIDTH, Math.round(px))) +} 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 index f4314ea2..c1631762 100644 --- a/apps/desktop-ui/src/store/__tests__/tool-sidebar-store.test.ts +++ b/apps/desktop-ui/src/store/__tests__/tool-sidebar-store.test.ts @@ -4,7 +4,7 @@ import { useToolSidebarStore } from "../tool-sidebar-store" beforeEach(() => { - useToolSidebarStore.setState({ collapsed: {} }) + useToolSidebarStore.setState({ collapsed: {}, width: {} }) }) describe("tool-sidebar-store", () => { @@ -33,4 +33,36 @@ describe("tool-sidebar-store", () => { setCollapsed("notes", true) expect(useToolSidebarStore.getState().collapsed["s3-drive"]).toBeUndefined() }) + + it("has no stored width until one is set", () => { + expect(useToolSidebarStore.getState().width["notes"]).toBeUndefined() + }) + + it("stores a width per tool", () => { + useToolSidebarStore.getState().setWidth("notes", 320) + expect(useToolSidebarStore.getState().width["notes"]).toBe(320) + expect(useToolSidebarStore.getState().width["bookmarks"]).toBeUndefined() + }) + + it("clamps a stored width to the allowed range", () => { + const { setWidth } = useToolSidebarStore.getState() + setWidth("notes", 50) + expect(useToolSidebarStore.getState().width["notes"]).toBe(200) + setWidth("notes", 5000) + expect(useToolSidebarStore.getState().width["notes"]).toBe(480) + }) + + it("resetWidth clears the key so the default applies again", () => { + useToolSidebarStore.getState().setWidth("notes", 320) + useToolSidebarStore.getState().resetWidth("notes") + expect(useToolSidebarStore.getState().width["notes"]).toBeUndefined() + }) + + it("width and collapse are independent", () => { + const { setWidth, setCollapsed } = useToolSidebarStore.getState() + setWidth("notes", 320) + setCollapsed("notes", true) + expect(useToolSidebarStore.getState().width["notes"]).toBe(320) + expect(useToolSidebarStore.getState().collapsed["notes"]).toBe(true) + }) }) diff --git a/apps/desktop-ui/src/store/tool-sidebar-store.ts b/apps/desktop-ui/src/store/tool-sidebar-store.ts index 5694945c..b2e7f0df 100644 --- a/apps/desktop-ui/src/store/tool-sidebar-store.ts +++ b/apps/desktop-ui/src/store/tool-sidebar-store.ts @@ -1,5 +1,6 @@ import { create } from 'zustand' import { persist } from 'zustand/middleware' +import { clampSidebarWidth } from '@/lib/tool-sidebar-rail' interface ToolSidebarState { /** @@ -7,19 +8,38 @@ interface ToolSidebarState { * default — so a tool that has never been collapsed stores nothing. */ collapsed: Record + /** + * Panel width in px keyed by tool id. A missing key means the 256px default, + * so a tool the user never dragged stores nothing. + */ + width: Record setCollapsed: (toolId: string, collapsed: boolean) => void toggle: (toolId: string) => void + setWidth: (toolId: string, px: number) => void + resetWidth: (toolId: string) => void } export const useToolSidebarStore = create()( persist( (set) => ({ collapsed: {}, + width: {}, setCollapsed: (toolId, collapsed) => set((s) => ({ collapsed: { ...s.collapsed, [toolId]: collapsed } })), toggle: (toolId) => set((s) => ({ collapsed: { ...s.collapsed, [toolId]: !s.collapsed[toolId] } })), + setWidth: (toolId, px) => + set((s) => ({ width: { ...s.width, [toolId]: clampSidebarWidth(px) } })), + resetWidth: (toolId) => + set((s) => { + const width = { ...s.width } + delete width[toolId] + return { width } + }), }), + // No version bump needed: `persist` shallow-merges the stored object over + // the initializer's, and payloads written before `width` existed simply have + // no such key, so the `{}` default survives. { name: 'tool-sidebar-storage' }, ), )