diff --git a/packages/app/src/components/dialog-settings.tsx b/packages/app/src/components/dialog-settings.tsx index 83cea131f5d..f63e2c339ac 100644 --- a/packages/app/src/components/dialog-settings.tsx +++ b/packages/app/src/components/dialog-settings.tsx @@ -8,6 +8,7 @@ import { SettingsGeneral } from "./settings-general" import { SettingsKeybinds } from "./settings-keybinds" import { SettingsProviders } from "./settings-providers" import { SettingsModels } from "./settings-models" +import { SettingsArchive } from "./settings-archive" export const DialogSettings: Component = () => { const language = useLanguage() @@ -47,6 +48,16 @@ export const DialogSettings: Component = () => { + +
+ {language.t("settings.section.data")} +
+ + + {language.t("settings.archive.title")} + +
+
@@ -67,6 +78,9 @@ export const DialogSettings: Component = () => { + + + ) diff --git a/packages/app/src/components/settings-archive.tsx b/packages/app/src/components/settings-archive.tsx new file mode 100644 index 00000000000..28ace7bad9b --- /dev/null +++ b/packages/app/src/components/settings-archive.tsx @@ -0,0 +1,188 @@ +import { Button } from "@opencode-ai/ui/button" +import { Icon } from "@opencode-ai/ui/icon" +import { RadioGroup } from "@opencode-ai/ui/radio-group" +import { getFilename } from "@opencode-ai/util/path" +import { Component, For, Show, createMemo, createResource, createSignal } from "solid-js" +import { useParams } from "@solidjs/router" +import { useGlobalSDK } from "@/context/global-sdk" +import { useGlobalSync } from "@/context/global-sync" +import { useLanguage } from "@/context/language" +import { useLayout } from "@/context/layout" +import { getRelativeTime } from "@/utils/time" +import { decode64 } from "@/utils/base64" +import type { Session } from "@opencode-ai/sdk/v2/client" +import { SessionSkeleton } from "@/pages/layout/sidebar-items" + +type FilterScope = "all" | "current" + +type ScopeOption = { value: FilterScope; label: "settings.archive.scope.all" | "settings.archive.scope.current" } + +const scopeOptions: ScopeOption[] = [ + { value: "all", label: "settings.archive.scope.all" }, + { value: "current", label: "settings.archive.scope.current" }, +] + +export const SettingsArchive: Component = () => { + const language = useLanguage() + const globalSDK = useGlobalSDK() + const globalSync = useGlobalSync() + const layout = useLayout() + const params = useParams() + const [removedIds, setRemovedIds] = createSignal>(new Set()) + + const projects = createMemo(() => globalSync.data.project) + const layoutProjects = createMemo(() => layout.projects.list()) + const hasMultipleProjects = createMemo(() => projects().length > 1) + const homedir = createMemo(() => globalSync.data.path.home) + + const defaultScope = () => (hasMultipleProjects() ? "current" : "all") + const [filterScope, setFilterScope] = createSignal(defaultScope()) + + const currentDirectory = createMemo(() => decode64(params.dir) ?? "") + + const currentProject = createMemo(() => { + const dir = currentDirectory() + if (!dir) return null + return layoutProjects().find((p) => p.worktree === dir || p.sandboxes?.includes(dir)) ?? null + }) + + const filteredProjects = createMemo(() => { + if (filterScope() === "current" && currentProject()) { + return [currentProject()!] + } + return layoutProjects() + }) + + const getSessionLabel = (session: Session) => { + const directory = session.directory + const home = homedir() + const path = home ? directory.replace(home, "~") : directory + + if (filterScope() === "current" && currentProject()) { + const current = currentProject() + const kind = + current && directory === current.worktree + ? language.t("workspace.type.local") + : language.t("workspace.type.sandbox") + const [store] = globalSync.child(directory, { bootstrap: false }) + const name = store.vcs?.branch ?? getFilename(directory) + return `${kind} : ${name || path}` + } + + return path + } + + const [archivedSessions] = createResource( + () => ({ scope: filterScope(), projects: filteredProjects() }), + async ({ projects }) => { + const allSessions: Session[] = [] + for (const project of projects) { + const directories = [project.worktree, ...(project.sandboxes ?? [])] + for (const directory of directories) { + const result = await globalSDK.client.experimental.session.list({ directory, archived: true }) + const sessions = result.data ?? [] + for (const session of sessions) { + allSessions.push(session) + } + } + } + return allSessions.sort((a, b) => (b.time?.updated ?? 0) - (a.time?.updated ?? 0)) + }, + { initialValue: [] }, + ) + + const displayedSessions = () => { + const sessions = archivedSessions() ?? [] + const removed = removedIds() + return sessions.filter((s) => !removed.has(s.id)) + } + + const currentScopeOption = () => scopeOptions.find((o) => o.value === filterScope()) + + const unarchiveSession = async (session: Session) => { + setRemovedIds((prev) => new Set(prev).add(session.id)) + await globalSDK.client.session.update({ + directory: session.directory, + sessionID: session.id, + time: { archived: null as any }, + }) + } + + const handleScopeChange = (option: ScopeOption | undefined) => { + if (!option) return + setRemovedIds(new Set()) + setFilterScope(option.value) + } + + return ( +
+
+
+

{language.t("settings.archive.title")}

+

{language.t("settings.archive.description")}

+
+
+ +
+ + o.value} + size="small" + label={(o) => language.t(o.label)} + onSelect={handleScopeChange} + /> + + + +
+ } + > + +
{language.t("settings.archive.none")}
+
+ } + > +
+ + {(session) => ( +
+
+
+ {session.title} + {getSessionLabel(session)} +
+
+
+ + {(updated) => ( + + {getRelativeTime(new Date(updated()).toISOString(), language.t)} + + )} + + +
+
+ )} +
+
+ + +
+ + ) +} diff --git a/packages/app/src/i18n/ar.ts b/packages/app/src/i18n/ar.ts index 16f2fbf4925..465c4b8b937 100644 --- a/packages/app/src/i18n/ar.ts +++ b/packages/app/src/i18n/ar.ts @@ -734,6 +734,11 @@ export const dict = { "workspace.reset.archived.one": "ستتم أرشفة جلسة واحدة.", "workspace.reset.archived.many": "ستتم أرشفة {{count}} جلسات.", "workspace.reset.note": "سيؤدي هذا إلى إعادة تعيين مساحة العمل لتتطابق مع الفرع الافتراضي.", + "settings.archive.title": "الجلسات المؤرشفة", + "settings.archive.description": "استعادة الجلسات المؤرشفة لجعلها مرئية في الشريط الجانبي.", + "settings.archive.none": "لا توجد جلسات مؤرشفة.", + "settings.archive.scope.all": "جميع المشاريع", + "settings.archive.scope.current": "المشروع الحالي", "common.open": "فتح", "dialog.releaseNotes.action.getStarted": "البدء", "dialog.releaseNotes.action.next": "التالي", diff --git a/packages/app/src/i18n/br.ts b/packages/app/src/i18n/br.ts index 26cf433e0e3..178989bc392 100644 --- a/packages/app/src/i18n/br.ts +++ b/packages/app/src/i18n/br.ts @@ -742,6 +742,11 @@ export const dict = { "workspace.reset.archived.one": "1 sessão será arquivada.", "workspace.reset.archived.many": "{{count}} sessões serão arquivadas.", "workspace.reset.note": "Isso redefinirá o espaço de trabalho para corresponder ao branch padrão.", + "settings.archive.title": "Sessões arquivadas", + "settings.archive.description": "Restaure sessões arquivadas para torná-las visíveis na barra lateral.", + "settings.archive.none": "Nenhuma sessão arquivada.", + "settings.archive.scope.all": "Todos os projetos", + "settings.archive.scope.current": "Projeto atual", "common.open": "Abrir", "dialog.releaseNotes.action.getStarted": "Começar", "dialog.releaseNotes.action.next": "Próximo", diff --git a/packages/app/src/i18n/bs.ts b/packages/app/src/i18n/bs.ts index 6c8198bd715..be2af40530d 100644 --- a/packages/app/src/i18n/bs.ts +++ b/packages/app/src/i18n/bs.ts @@ -819,6 +819,11 @@ export const dict = { "workspace.reset.archived.one": "1 sesija će biti arhivirana.", "workspace.reset.archived.many": "Biće arhivirano {{count}} sesija.", "workspace.reset.note": "Ovo će resetovati radni prostor da odgovara podrazumijevanoj grani.", + "settings.archive.title": "Arhivirane sesije", + "settings.archive.description": "Vrati arhivirane sesije da bi bile vidljive u bočnoj traci.", + "settings.archive.none": "Nema arhiviranih sesija.", + "settings.archive.scope.all": "Svi projekti", + "settings.archive.scope.current": "Trenutni projekt", "common.open": "Otvori", "dialog.releaseNotes.action.getStarted": "Započni", "dialog.releaseNotes.action.next": "Sljedeće", diff --git a/packages/app/src/i18n/da.ts b/packages/app/src/i18n/da.ts index 11da681760a..f268f51ac3f 100644 --- a/packages/app/src/i18n/da.ts +++ b/packages/app/src/i18n/da.ts @@ -813,6 +813,11 @@ export const dict = { "workspace.reset.archived.one": "1 session vil blive arkiveret.", "workspace.reset.archived.many": "{{count}} sessioner vil blive arkiveret.", "workspace.reset.note": "Dette vil nulstille arbejdsområdet til at matche hovedgrenen.", + "settings.archive.title": "Arkiverede sessioner", + "settings.archive.description": "Gendan arkiverede sessioner for at gøre dem synlige i sidebjælken.", + "settings.archive.none": "Ingen arkiverede sessioner.", + "settings.archive.scope.all": "Alle projekter", + "settings.archive.scope.current": "Nuværende projekt", "common.open": "Åbn", "dialog.releaseNotes.action.getStarted": "Kom i gang", "dialog.releaseNotes.action.next": "Næste", diff --git a/packages/app/src/i18n/de.ts b/packages/app/src/i18n/de.ts index 51b9ec35315..7293b3af152 100644 --- a/packages/app/src/i18n/de.ts +++ b/packages/app/src/i18n/de.ts @@ -751,6 +751,12 @@ export const dict = { "workspace.reset.archived.one": "1 Sitzung wird archiviert.", "workspace.reset.archived.many": "{{count}} Sitzungen werden archiviert.", "workspace.reset.note": "Dadurch wird der Arbeitsbereich auf den Standard-Branch zurückgesetzt.", + + "settings.archive.title": "Archivierte Sitzungen", + "settings.archive.description": "Archivierte Sitzungen wiederherstellen, um sie in der Seitenleiste anzuzeigen.", + "settings.archive.none": "Keine archivierten Sitzungen.", + "settings.archive.scope.all": "Alle Projekte", + "settings.archive.scope.current": "Aktuelles Projekt", "common.open": "Öffnen", "dialog.releaseNotes.action.getStarted": "Loslegen", "dialog.releaseNotes.action.next": "Weiter", diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 7e95fd739df..3af50e65a83 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -585,6 +585,7 @@ export const dict = { "common.rename": "Rename", "common.reset": "Reset", "common.archive": "Archive", + "common.unarchive": "Unarchive", "common.delete": "Delete", "common.close": "Close", "common.edit": "Edit", @@ -613,6 +614,7 @@ export const dict = { "settings.section.desktop": "Desktop", "settings.section.server": "Server", + "settings.section.data": "Data", "settings.tab.general": "General", "settings.tab.shortcuts": "Shortcuts", "settings.desktop.section.wsl": "WSL", @@ -844,4 +846,10 @@ export const dict = { "workspace.reset.archived.one": "1 session will be archived.", "workspace.reset.archived.many": "{{count}} sessions will be archived.", "workspace.reset.note": "This will reset the workspace to match the default branch.", + + "settings.archive.title": "Archived Sessions", + "settings.archive.description": "Restore archived sessions to make them visible in the sidebar.", + "settings.archive.none": "No archived sessions.", + "settings.archive.scope.all": "All projects", + "settings.archive.scope.current": "Current project", } diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts index 2665a808508..1989dbd611c 100644 --- a/packages/app/src/i18n/es.ts +++ b/packages/app/src/i18n/es.ts @@ -825,6 +825,12 @@ export const dict = { "workspace.reset.archived.one": "1 sesión será archivada.", "workspace.reset.archived.many": "{{count}} sesiones serán archivadas.", "workspace.reset.note": "Esto restablecerá el espacio de trabajo para coincidir con la rama predeterminada.", + + "settings.archive.title": "Sesiones archivadas", + "settings.archive.description": "Restaura las sesiones archivadas para hacerlas visibles en la barra lateral.", + "settings.archive.none": "No hay sesiones archivadas.", + "settings.archive.scope.all": "Todos los proyectos", + "settings.archive.scope.current": "Proyecto actual", "common.open": "Abrir", "dialog.releaseNotes.action.getStarted": "Comenzar", "dialog.releaseNotes.action.next": "Siguiente", diff --git a/packages/app/src/i18n/fr.ts b/packages/app/src/i18n/fr.ts index 1e67db19333..b9426e50ebf 100644 --- a/packages/app/src/i18n/fr.ts +++ b/packages/app/src/i18n/fr.ts @@ -749,6 +749,11 @@ export const dict = { "workspace.reset.archived.one": "1 session sera archivée.", "workspace.reset.archived.many": "{{count}} sessions seront archivées.", "workspace.reset.note": "Cela réinitialisera l'espace de travail pour correspondre à la branche par défaut.", + "settings.archive.title": "Sessions archivées", + "settings.archive.description": "Restaurez les sessions archivées pour les rendre visibles dans la barre latérale.", + "settings.archive.none": "Aucune session archivée.", + "settings.archive.scope.all": "Tous les Projets", + "settings.archive.scope.current": "Projet actuel", "common.open": "Ouvrir", "dialog.releaseNotes.action.getStarted": "Commencer", "dialog.releaseNotes.action.next": "Suivant", diff --git a/packages/app/src/i18n/ja.ts b/packages/app/src/i18n/ja.ts index ecd38d33249..320389637eb 100644 --- a/packages/app/src/i18n/ja.ts +++ b/packages/app/src/i18n/ja.ts @@ -738,6 +738,12 @@ export const dict = { "workspace.reset.archived.one": "1つのセッションがアーカイブされます。", "workspace.reset.archived.many": "{{count}}個のセッションがアーカイブされます。", "workspace.reset.note": "これにより、ワークスペースはデフォルトブランチと一致するようにリセットされます。", + + "settings.archive.title": "アーカイブされたセッション", + "settings.archive.description": "アーカイブされたセッションを復元してサイドバーに表示します。", + "settings.archive.none": "アーカイブされたセッションはありません。", + "settings.archive.scope.all": "すべてのプロジェクト", + "settings.archive.scope.current": "現在のプロジェクト", "common.open": "開く", "dialog.releaseNotes.action.getStarted": "始める", "dialog.releaseNotes.action.next": "次へ", diff --git a/packages/app/src/i18n/ko.ts b/packages/app/src/i18n/ko.ts index 8f54b8abdc7..7c97a9a8ce9 100644 --- a/packages/app/src/i18n/ko.ts +++ b/packages/app/src/i18n/ko.ts @@ -738,6 +738,12 @@ export const dict = { "workspace.reset.archived.one": "1개의 세션이 보관됩니다.", "workspace.reset.archived.many": "{{count}}개의 세션이 보관됩니다.", "workspace.reset.note": "이 작업은 작업 공간을 기본 브랜치와 일치하도록 재설정합니다.", + + "settings.archive.title": "보관된 세션", + "settings.archive.description": "보관된 세션을 복원하여 사이드바에 표시합니다.", + "settings.archive.none": "보관된 세션이 없습니다.", + "settings.archive.scope.all": "모든 프로젝트", + "settings.archive.scope.current": "현재 프로젝트", "common.open": "열기", "dialog.releaseNotes.action.getStarted": "시작하기", "dialog.releaseNotes.action.next": "다음", diff --git a/packages/app/src/i18n/no.ts b/packages/app/src/i18n/no.ts index 0c94046eb06..56e32c3fce2 100644 --- a/packages/app/src/i18n/no.ts +++ b/packages/app/src/i18n/no.ts @@ -821,6 +821,12 @@ export const dict = { "workspace.reset.archived.one": "1 sesjon vil bli arkivert.", "workspace.reset.archived.many": "{{count}} sesjoner vil bli arkivert.", "workspace.reset.note": "Dette vil tilbakestille arbeidsområdet til å samsvare med standardgrenen.", + + "settings.archive.title": "Arkiverte økter", + "settings.archive.description": "Gjenopprett arkiverte økter for å gjøre dem synlige i sidefeltet.", + "settings.archive.none": "Ingen arkiverte økter.", + "settings.archive.scope.all": "Alle prosjekter", + "settings.archive.scope.current": "Nåværende prosjekt", "common.open": "Åpne", "dialog.releaseNotes.action.getStarted": "Kom i gang", "dialog.releaseNotes.action.next": "Neste", diff --git a/packages/app/src/i18n/pl.ts b/packages/app/src/i18n/pl.ts index 59c0513be62..05588958315 100644 --- a/packages/app/src/i18n/pl.ts +++ b/packages/app/src/i18n/pl.ts @@ -740,6 +740,11 @@ export const dict = { "workspace.reset.archived.one": "1 sesja zostanie zarchiwizowana.", "workspace.reset.archived.many": "{{count}} sesji zostanie zarchiwizowanych.", "workspace.reset.note": "To zresetuje przestrzeń roboczą, aby odpowiadała domyślnej gałęzi.", + "settings.archive.title": "Zarchiwizowane sesje", + "settings.archive.description": "Przywróć zarchiwizowane sesje, aby były widoczne na pasku bocznym.", + "settings.archive.none": "Brak zarchiwizowanych sesji.", + "settings.archive.scope.all": "Wszystkie projekty", + "settings.archive.scope.current": "Bieżący projekt", "common.open": "Otwórz", "dialog.releaseNotes.action.getStarted": "Rozpocznij", "dialog.releaseNotes.action.next": "Dalej", diff --git a/packages/app/src/i18n/ru.ts b/packages/app/src/i18n/ru.ts index 2071eaae7b9..6b51c883648 100644 --- a/packages/app/src/i18n/ru.ts +++ b/packages/app/src/i18n/ru.ts @@ -821,6 +821,11 @@ export const dict = { "workspace.reset.archived.one": "1 сессия будет архивирована.", "workspace.reset.archived.many": "{{count}} сессий будет архивировано.", "workspace.reset.note": "Рабочее пространство будет сброшено в соответствие с веткой по умолчанию.", + "settings.archive.title": "Архивированные сессии", + "settings.archive.description": "Восстановите архивированные сессии, чтобы они отображались на боковой панели.", + "settings.archive.none": "Нет архивированных сессий.", + "settings.archive.scope.all": "Все проекты", + "settings.archive.scope.current": "Текущий проект", "common.open": "Открыть", "dialog.releaseNotes.action.getStarted": "Начать", "dialog.releaseNotes.action.next": "Далее", diff --git a/packages/app/src/i18n/th.ts b/packages/app/src/i18n/th.ts index 9871555536f..42b02bb2a76 100644 --- a/packages/app/src/i18n/th.ts +++ b/packages/app/src/i18n/th.ts @@ -811,6 +811,12 @@ export const dict = { "workspace.reset.archived.one": "1 เซสชันจะถูกจัดเก็บ", "workspace.reset.archived.many": "{{count}} เซสชันจะถูกจัดเก็บ", "workspace.reset.note": "สิ่งนี้จะรีเซ็ตพื้นที่ทำงานให้ตรงกับสาขาเริ่มต้น", + + "settings.archive.title": "เซสชันที่จัดเก็บ", + "settings.archive.description": "กู้คืนเซสชันที่จัดเก็บเพื่อให้แสดงในแถบด้านข้าง", + "settings.archive.none": "ไม่มีเซสชันที่จัดเก็บ", + "settings.archive.scope.all": "โปรเจกต์ทั้งหมด", + "settings.archive.scope.current": "โปรเจกต์ปัจจุบัน", "common.open": "เปิด", "dialog.releaseNotes.action.getStarted": "เริ่มต้น", "dialog.releaseNotes.action.next": "ถัดไป", diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index e72d4c0e3be..9096263921d 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -809,6 +809,12 @@ export const dict = { "workspace.reset.archived.one": "将归档 1 个会话。", "workspace.reset.archived.many": "将归档 {{count}} 个会话。", "workspace.reset.note": "这将把工作区重置为与默认分支一致。", + + "settings.archive.title": "归档会话", + "settings.archive.description": "恢复归档会话以使其在侧边栏中可见。", + "settings.archive.none": "没有归档会话。", + "settings.archive.scope.all": "所有项目", + "settings.archive.scope.current": "当前项目", "common.open": "打开", "dialog.releaseNotes.action.getStarted": "开始", "dialog.releaseNotes.action.next": "下一步", diff --git a/packages/app/src/i18n/zht.ts b/packages/app/src/i18n/zht.ts index 70421dfe103..2bb415460c3 100644 --- a/packages/app/src/i18n/zht.ts +++ b/packages/app/src/i18n/zht.ts @@ -804,6 +804,12 @@ export const dict = { "workspace.reset.archived.one": "將封存 1 個工作階段。", "workspace.reset.archived.many": "將封存 {{count}} 個工作階段。", "workspace.reset.note": "這將把工作區重設為與預設分支一致。", + + "settings.archive.title": "封存工作階段", + "settings.archive.description": "恢復封存的工作階段以使其在側邊欄中可見。", + "settings.archive.none": "沒有封存的工作階段。", + "settings.archive.scope.all": "所有專案", + "settings.archive.scope.current": "目前專案", "common.open": "打開", "dialog.releaseNotes.action.getStarted": "開始", "dialog.releaseNotes.action.next": "下一步", diff --git a/packages/opencode/src/server/routes/session.ts b/packages/opencode/src/server/routes/session.ts index a391979520d..21ca1af82e6 100644 --- a/packages/opencode/src/server/routes/session.ts +++ b/packages/opencode/src/server/routes/session.ts @@ -263,13 +263,19 @@ export const SessionRoutes = lazy(() => sessionID: z.string(), }), ), + validator( + "query", + z.object({ + directory: z.string().optional(), + }), + ), validator( "json", z.object({ title: z.string().optional(), time: z .object({ - archived: z.number().optional(), + archived: z.number().nullable().optional(), }) .optional(), }), @@ -282,8 +288,8 @@ export const SessionRoutes = lazy(() => if (updates.title !== undefined) { session = await Session.setTitle({ sessionID, title: updates.title }) } - if (updates.time?.archived !== undefined) { - session = await Session.setArchived({ sessionID, time: updates.time.archived }) + if (updates.time !== undefined && "archived" in updates.time) { + session = await Session.setArchived({ sessionID, time: updates.time.archived ?? undefined }) } return c.json(session) diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index 22de477f8d1..a22bf761311 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -10,7 +10,7 @@ import { Flag } from "../flag/flag" import { Identifier } from "../id/id" import { Installation } from "../installation" -import { Database, NotFoundError, eq, and, or, gte, isNull, desc, like, inArray, lt } from "../storage/db" +import { Database, NotFoundError, eq, and, or, gte, isNull, isNotNull, desc, like, inArray, lt } from "../storage/db" import type { SQL } from "../storage/db" import { SessionTable, MessageTable, PartTable } from "./session.sql" import { ProjectTable } from "../project/project.sql" @@ -396,7 +396,7 @@ export namespace Session { return Database.use((db) => { const row = db .update(SessionTable) - .set({ time_archived: input.time }) + .set({ time_archived: input.time ?? null }) .where(eq(SessionTable.id, input.sessionID)) .returning() .get() @@ -590,6 +590,9 @@ export namespace Session { if (input?.search) { conditions.push(like(SessionTable.title, `%${input.search}%`)) } + if (input?.archived) { + conditions.push(isNotNull(SessionTable.time_archived)) + } if (!input?.archived) { conditions.push(isNull(SessionTable.time_archived)) }