feat(desktop): workspace system with save/switch/restore (v1c)

Add workspace presets for different usage modes. Each workspace stores
layout mode + column configuration. Switching destroys current layout
and rebuilds from saved config (no background state).

- Add Workspace data model with WorkspaceColumn
- Add WorkspaceManager with save/load/switch/add/delete
- Add WorkspaceIcons registry for Material icon resolution
- Add WorkspaceBar to AppDrawer footer with workspace chips
- Add DeckState.loadFromWorkspace() for column rebuilding
- Add workspaces persistence to DesktopPreferences
- Default workspace: "Social" (Home + Notifications + DMs)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
nrobi144
2026-04-17 13:42:30 +03:00
parent 28e9b8202f
commit e528656ebd
6 changed files with 396 additions and 1 deletions
@@ -69,6 +69,14 @@ object DesktopPreferences {
prefs.put(KEY_LAYOUT_MODE, value)
}
private const val KEY_WORKSPACES = "workspaces"
var workspaces: String
get() = prefs.get(KEY_WORKSPACES, "")
set(value) {
prefs.put(KEY_WORKSPACES, value)
}
private const val KEY_PINNED_NAV_ITEMS = "pinned_nav_items"
var pinnedNavItems: String
@@ -96,6 +96,7 @@ import com.vitorpamplona.amethyst.desktop.ui.deck.DeckState
import com.vitorpamplona.amethyst.desktop.ui.deck.PinnedNavBarState
import com.vitorpamplona.amethyst.desktop.ui.deck.SinglePaneLayout
import com.vitorpamplona.amethyst.desktop.ui.deck.SinglePaneState
import com.vitorpamplona.amethyst.desktop.ui.deck.WorkspaceManager
import com.vitorpamplona.amethyst.desktop.ui.media.LocalAwtWindow
import com.vitorpamplona.amethyst.desktop.ui.media.LocalIsImmersiveFullscreen
import com.vitorpamplona.amethyst.desktop.ui.media.LocalWindowState
@@ -192,6 +193,7 @@ fun main() {
var replyToNote by remember { mutableStateOf<com.vitorpamplona.quartz.nip01Core.core.Event?>(null) }
val deckScope = rememberCoroutineScope()
val deckState = remember { DeckState(deckScope).also { it.load() } }
val workspaceManager = remember { WorkspaceManager(deckScope).also { it.load() } }
val accountManager = remember { AccountManager.create() }
val accountState by accountManager.accountState.collectAsState()
var showAppDrawer by remember { mutableStateOf(false) }
@@ -446,6 +448,7 @@ fun main() {
App(
layoutMode = layoutMode,
deckState = deckState,
workspaceManager = workspaceManager,
accountManager = accountManager,
showComposeDialog = showComposeDialog,
showAppDrawer = showAppDrawer,
@@ -477,6 +480,7 @@ fun main() {
fun App(
layoutMode: LayoutMode,
deckState: DeckState,
workspaceManager: WorkspaceManager,
accountManager: AccountManager,
showComposeDialog: Boolean,
showAppDrawer: Boolean,
@@ -699,6 +703,7 @@ fun App(
MainContent(
layoutMode = layoutMode,
deckState = deckState,
workspaceManager = workspaceManager,
singlePaneState = singlePaneState,
pinnedNavBarState = pinnedNavBarState,
relayManager = relayManager,
@@ -736,6 +741,10 @@ fun App(
emptySet()
},
pinnedNavBarState = pinnedNavBarState,
workspaceManager = workspaceManager,
onSwitchWorkspace = { ws ->
deckState.loadFromWorkspace(ws.columns)
},
onSelectScreen = { type ->
when (layoutMode) {
LayoutMode.DECK -> {
@@ -773,6 +782,7 @@ fun App(
fun MainContent(
layoutMode: LayoutMode,
deckState: DeckState,
workspaceManager: WorkspaceManager,
singlePaneState: SinglePaneState,
pinnedNavBarState: PinnedNavBarState,
relayManager: DesktopRelayConnectionManager,
@@ -59,6 +59,7 @@ import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -216,6 +217,8 @@ private class AppDrawerState {
fun AppDrawer(
openColumnTypes: Set<String>,
pinnedNavBarState: PinnedNavBarState,
workspaceManager: WorkspaceManager,
onSwitchWorkspace: (Workspace) -> Unit,
onSelectScreen: (DeckColumnType) -> Unit,
onDismiss: () -> Unit,
) {
@@ -306,7 +309,17 @@ fun AppDrawer(
if (state.awaitingHashtag) {
HashtagInputSection(state, onSelectScreen, onDismiss)
} else {
DrawerGrid(state, openColumnTypes, pinnedNavBarState, onSelectScreen, onDismiss)
Box(Modifier.weight(1f)) {
DrawerGrid(state, openColumnTypes, pinnedNavBarState, onSelectScreen, onDismiss)
}
// Workspace bar at the bottom
WorkspaceBar(
workspaceManager = workspaceManager,
onSwitchWorkspace = { ws ->
onSwitchWorkspace(ws)
onDismiss()
},
)
}
}
}
@@ -528,3 +541,47 @@ private fun HashtagInputSection(
}
}
}
@Composable
private fun WorkspaceBar(
workspaceManager: WorkspaceManager,
onSwitchWorkspace: (Workspace) -> Unit,
) {
val workspaces by workspaceManager.workspaces.collectAsState()
val activeIndex by workspaceManager.activeIndex.collectAsState()
androidx.compose.material3.HorizontalDivider()
Row(
modifier = Modifier.fillMaxWidth().padding(12.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
"Workspaces",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
workspaces.forEachIndexed { index, ws ->
val isActive = index == activeIndex
androidx.compose.material3.FilterChip(
selected = isActive,
onClick = {
if (!isActive) {
val switched = workspaceManager.switchTo(index)
if (switched != null) onSwitchWorkspace(switched)
}
},
label = { Text(ws.name, style = MaterialTheme.typography.labelSmall) },
leadingIcon = {
Icon(
WorkspaceIcons.resolve(ws.iconName),
contentDescription = ws.name,
modifier = Modifier.size(16.dp),
)
},
)
}
}
}
@@ -256,6 +256,20 @@ class DeckState(
}
}
fun loadFromWorkspace(workspaceColumns: List<Workspace.WorkspaceColumn>) {
val loaded =
workspaceColumns.mapNotNull { col ->
val entry = mapOf("type" to col.typeKey, "param" to col.param)
val type = parseColumnType(entry) ?: return@mapNotNull null
DeckColumn(
type = type,
width = col.width.coerceIn(MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH),
)
}
_columns.value = loaded.ifEmpty { DEFAULT_COLUMNS }
_focusedColumnIndex.value = 0
}
companion object {
const val MIN_COLUMN_WIDTH = 300f
const val MAX_COLUMN_WIDTH = 800f
@@ -0,0 +1,81 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.desktop.ui.deck
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bookmark
import androidx.compose.material.icons.filled.Chat
import androidx.compose.material.icons.filled.Code
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Explore
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.Groups
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.MenuBook
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.SportsEsports
import androidx.compose.material.icons.filled.Star
import androidx.compose.material.icons.filled.Work
import androidx.compose.ui.graphics.vector.ImageVector
import com.vitorpamplona.amethyst.desktop.LayoutMode
data class Workspace(
val id: String =
java.util.UUID
.randomUUID()
.toString(),
val name: String,
val iconName: String,
val layoutMode: LayoutMode,
val columns: List<WorkspaceColumn>,
val singlePaneScreen: String? = null,
) {
data class WorkspaceColumn(
val typeKey: String,
val param: String? = null,
val width: Float = 400f,
)
}
object WorkspaceIcons {
private val icons: Map<String, ImageVector> =
mapOf(
"Groups" to Icons.Default.Groups,
"Edit" to Icons.Default.Edit,
"MenuBook" to Icons.Default.MenuBook,
"Home" to Icons.Default.Home,
"Chat" to Icons.Default.Chat,
"Search" to Icons.Default.Search,
"SportsEsports" to Icons.Default.SportsEsports,
"Bookmark" to Icons.Default.Bookmark,
"Explore" to Icons.Default.Explore,
"Person" to Icons.Default.Person,
"Star" to Icons.Default.Star,
"Favorite" to Icons.Default.Favorite,
"Work" to Icons.Default.Work,
"Code" to Icons.Default.Code,
)
val availableNames: List<String> = icons.keys.sorted()
fun resolve(name: String): ImageVector = icons[name] ?: Icons.Default.Home
}
@@ -0,0 +1,225 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.desktop.ui.deck
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import com.vitorpamplona.amethyst.desktop.DesktopPreferences
import com.vitorpamplona.amethyst.desktop.LayoutMode
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
class WorkspaceManager(
private val saveScope: CoroutineScope,
) {
private val _workspaces = MutableStateFlow(listOf(DEFAULT_WORKSPACE))
val workspaces: StateFlow<List<Workspace>> = _workspaces.asStateFlow()
private val _activeIndex = MutableStateFlow(0)
val activeIndex: StateFlow<Int> = _activeIndex.asStateFlow()
val activeWorkspace: Workspace
get() = _workspaces.value.getOrElse(_activeIndex.value) { _workspaces.value.first() }
private var saveJob: Job? = null
fun switchTo(index: Int): Workspace? {
if (index !in _workspaces.value.indices) return null
_activeIndex.value = index
scheduleSave()
return activeWorkspace
}
fun saveCurrentColumns(columns: List<DeckColumn>) {
_workspaces.update { wsList ->
wsList.mapIndexed { idx, ws ->
if (idx == _activeIndex.value) {
ws.copy(
columns =
columns.map { col ->
Workspace.WorkspaceColumn(
typeKey = col.type.typeKey(),
param =
when (col.type) {
is DeckColumnType.Profile -> col.type.pubKeyHex
is DeckColumnType.Thread -> col.type.noteId
is DeckColumnType.Hashtag -> col.type.tag
else -> null
},
width = col.width,
)
},
)
} else {
ws
}
}
}
scheduleSave()
}
fun addWorkspace(workspace: Workspace) {
if (_workspaces.value.size >= MAX_WORKSPACES) return
_workspaces.update { it + workspace }
scheduleSave()
}
fun updateWorkspace(workspace: Workspace) {
_workspaces.update { wsList ->
wsList.map { if (it.id == workspace.id) workspace else it }
}
scheduleSave()
}
fun deleteWorkspace(id: String) {
if (_workspaces.value.size <= 1) return
val idx = _workspaces.value.indexOfFirst { it.id == id }
_workspaces.update { it.filter { ws -> ws.id != id } }
if (_activeIndex.value >= _workspaces.value.size) {
_activeIndex.value = _workspaces.value.size - 1
}
if (idx == _activeIndex.value || _activeIndex.value >= _workspaces.value.size) {
_activeIndex.value = 0
}
scheduleSave()
}
private fun scheduleSave() {
saveJob?.cancel()
saveJob =
saveScope.launch {
delay(SAVE_DEBOUNCE_MS)
save()
}
}
fun save() {
try {
val data =
mapOf(
"activeIndex" to _activeIndex.value,
"workspaces" to
_workspaces.value.map { ws ->
mapOf(
"id" to ws.id,
"name" to ws.name,
"iconName" to ws.iconName,
"layoutMode" to ws.layoutMode.name,
"singlePaneScreen" to ws.singlePaneScreen,
"columns" to
ws.columns.map { col ->
mapOf(
"typeKey" to col.typeKey,
"param" to col.param,
"width" to col.width,
)
},
)
},
)
DesktopPreferences.workspaces = mapper.writeValueAsString(data)
} catch (e: Exception) {
println("WorkspaceManager: failed to save: ${e.message}")
}
}
fun load() {
try {
val json = DesktopPreferences.workspaces
if (json.isBlank()) return
val data: Map<String, Any?> = mapper.readValue(json)
val activeIdx = (data["activeIndex"] as? Number)?.toInt() ?: 0
@Suppress("UNCHECKED_CAST")
val wsList = data["workspaces"] as? List<Map<String, Any?>> ?: return
val loaded =
wsList.mapNotNull { entry ->
try {
val name = entry["name"] as? String ?: return@mapNotNull null
val iconName = entry["iconName"] as? String ?: "Home"
val layoutMode =
try {
LayoutMode.valueOf(entry["layoutMode"] as? String ?: "DECK")
} catch (e: Exception) {
LayoutMode.DECK
}
val singlePaneScreen = entry["singlePaneScreen"] as? String
@Suppress("UNCHECKED_CAST")
val columns =
(entry["columns"] as? List<Map<String, Any?>>)?.map { col ->
Workspace.WorkspaceColumn(
typeKey = col["typeKey"] as? String ?: "home",
param = col["param"] as? String,
width = (col["width"] as? Number)?.toFloat() ?: 400f,
)
} ?: emptyList()
Workspace(
id =
entry["id"] as? String ?: java.util.UUID
.randomUUID()
.toString(),
name = name,
iconName = iconName,
layoutMode = layoutMode,
columns = columns,
singlePaneScreen = singlePaneScreen,
)
} catch (e: Exception) {
null
}
}
if (loaded.isNotEmpty()) {
_workspaces.value = loaded
_activeIndex.value = activeIdx.coerceIn(loaded.indices)
}
} catch (e: Exception) {
println("WorkspaceManager: failed to load: ${e.message}")
}
}
companion object {
const val MAX_WORKSPACES = 9
private const val SAVE_DEBOUNCE_MS = 500L
private val mapper = jacksonObjectMapper()
val DEFAULT_WORKSPACE =
Workspace(
id = "default-social",
name = "Social",
iconName = "Groups",
layoutMode = LayoutMode.DECK,
columns =
listOf(
Workspace.WorkspaceColumn("home"),
Workspace.WorkspaceColumn("notifications"),
Workspace.WorkspaceColumn("messages"),
),
)
}
}