Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Showing 32 changed files with 3784 additions and 16 deletions.
| @@ -39,5 +39,6 @@ dependencies { | ||
| 39 | 39 | implementation(libs.coroutines.android) |
| 40 | 40 | implementation(libs.serialization.json) |
| 41 | 41 | implementation(libs.okhttp) |
| 42 | + implementation(libs.code.scanner) | |
| 42 | 43 | testImplementation(libs.junit) |
| 43 | 44 | } |
| @@ -13,6 +13,11 @@ | ||
| 13 | 13 | android:usesCleartextTraffic="true" |
| 14 | 14 | android:supportsRtl="true"> |
| 15 | 15 | |
| 16 | + <!-- Fetches Google's QR code scanner with the app, for pairing. --> | |
| 17 | + <meta-data | |
| 18 | + android:name="com.google.mlkit.vision.DEPENDENCIES" | |
| 19 | + android:value="barcode_ui" /> | |
| 20 | + | |
| 16 | 21 | <activity |
| 17 | 22 | android:name=".MainActivity" |
| 18 | 23 | android:exported="true" |
| @@ -66,21 +66,22 @@ class DevicePrefs(private val store: SharedPreferences) { | ||
| 66 | 66 | "time_format" to "system", |
| 67 | 67 | "fold_turns" to true, |
| 68 | 68 | "show_costs" to true, |
| 69 | - "follow_up" to "steer", | |
| 70 | - "streaming" to "token", | |
| 69 | + "follow_up" to "queue", | |
| 70 | + "streaming" to "paragraphs", | |
| 71 | 71 | "slash_skills" to true, |
| 72 | 72 | "inline_ui" to true, |
| 73 | - "hide_whitespace" to false, | |
| 74 | - "diff_collapsed" to false, | |
| 73 | + "hide_whitespace" to true, | |
| 74 | + "diff_collapsed" to true, | |
| 75 | 75 | "diff_side_by_side" to false, |
| 76 | 76 | "composer_context" to false, |
| 77 | 77 | "plan_mode" to false, |
| 78 | 78 | "context_meter" to false, |
| 79 | 79 | "confirm_unpin" to false, |
| 80 | - "confirm_archive" to true, | |
| 80 | + "confirm_archive" to false, | |
| 81 | + "confirm_settle" to false, | |
| 81 | 82 | "confirm_delete" to true, |
| 82 | 83 | "thread_notifications" to "notifications", |
| 83 | - "working_section" to true, | |
| 84 | + "working_section" to false, | |
| 84 | 85 | "show_settled" to true, |
| 85 | 86 | ) |
| 86 | 87 | } |
| @@ -69,6 +69,7 @@ enum class Lucide(val res: Int) { | ||
| 69 | 69 | FolderGit2(R.drawable.lucide_folder_git_2), |
| 70 | 70 | FolderOpen(R.drawable.lucide_folder_open), |
| 71 | 71 | FolderPlus(R.drawable.lucide_folder_plus), |
| 72 | + FolderUp(R.drawable.lucide_folder_up), | |
| 72 | 73 | Gauge(R.drawable.lucide_gauge), |
| 73 | 74 | GitBranch(R.drawable.lucide_git_branch), |
| 74 | 75 | GitCommitHorizontal(R.drawable.lucide_git_commit_horizontal), |
| @@ -86,6 +87,7 @@ enum class Lucide(val res: Int) { | ||
| 86 | 87 | Keyboard(R.drawable.lucide_keyboard), |
| 87 | 88 | Laptop(R.drawable.lucide_laptop), |
| 88 | 89 | Layers(R.drawable.lucide_layers), |
| 90 | + Link(R.drawable.lucide_link), | |
| 89 | 91 | Link2(R.drawable.lucide_link_2), |
| 90 | 92 | List(R.drawable.lucide_list), |
| 91 | 93 | ListChecks(R.drawable.lucide_list_checks), |
| @@ -114,7 +116,9 @@ enum class Lucide(val res: Int) { | ||
| 114 | 116 | PanelRightClose(R.drawable.lucide_panel_right_close), |
| 115 | 117 | PanelRightOpen(R.drawable.lucide_panel_right_open), |
| 116 | 118 | Paperclip(R.drawable.lucide_paperclip), |
| 119 | + PenLine(R.drawable.lucide_pen_line), | |
| 117 | 120 | Pencil(R.drawable.lucide_pencil), |
| 121 | + PencilRuler(R.drawable.lucide_pencil_ruler), | |
| 118 | 122 | Pi(R.drawable.lucide_pi), |
| 119 | 123 | Pin(R.drawable.lucide_pin), |
| 120 | 124 | PinOff(R.drawable.lucide_pin_off), |
| @@ -1,9 +1,318 @@ | ||
| 1 | 1 | package cc.vectrum.code.ui.pair |
| 2 | 2 | |
| 3 | + import androidx.activity.compose.BackHandler | |
| 4 | + import androidx.compose.foundation.Image | |
| 5 | + import androidx.compose.foundation.layout.Arrangement | |
| 6 | + import androidx.compose.foundation.layout.Box | |
| 7 | + import androidx.compose.foundation.layout.Column | |
| 8 | + import androidx.compose.foundation.layout.Row | |
| 9 | + import androidx.compose.foundation.layout.defaultMinSize | |
| 10 | + import androidx.compose.foundation.layout.fillMaxWidth | |
| 11 | + import androidx.compose.foundation.layout.padding | |
| 12 | + import androidx.compose.foundation.layout.size | |
| 3 | 13 | import androidx.compose.runtime.Composable |
| 14 | + import androidx.compose.runtime.LaunchedEffect | |
| 15 | + import androidx.compose.runtime.getValue | |
| 16 | + import androidx.compose.runtime.mutableStateOf | |
| 17 | + import androidx.compose.runtime.remember | |
| 18 | + import androidx.compose.runtime.setValue | |
| 19 | + import androidx.compose.ui.Alignment | |
| 20 | + import androidx.compose.ui.Modifier | |
| 21 | + import androidx.compose.ui.graphics.Color | |
| 22 | + import androidx.compose.ui.graphics.ColorFilter | |
| 23 | + import androidx.compose.ui.res.painterResource | |
| 24 | + import androidx.compose.ui.text.TextRange | |
| 25 | + import androidx.compose.ui.text.font.FontWeight | |
| 26 | + import androidx.compose.ui.text.input.TextFieldValue | |
| 27 | + import androidx.compose.ui.unit.dp | |
| 28 | + import androidx.compose.ui.unit.sp | |
| 29 | + import cc.vectrum.code.R | |
| 4 | 30 | import cc.vectrum.code.data.AppState |
| 5 | 31 | import cc.vectrum.code.data.HostState |
| 32 | + import cc.vectrum.code.net.ClientMessage | |
| 33 | + import cc.vectrum.code.net.ConnectionStatus | |
| 34 | + import cc.vectrum.code.net.Folder | |
| 35 | + import cc.vectrum.code.net.ProjectSource | |
| 36 | + import cc.vectrum.code.theme.Cap | |
| 37 | + import cc.vectrum.code.theme.CapKind | |
| 38 | + import cc.vectrum.code.theme.CapSize | |
| 39 | + import cc.vectrum.code.theme.Tag | |
| 40 | + import cc.vectrum.code.theme.Txt | |
| 41 | + import cc.vectrum.code.theme.palette | |
| 42 | + import cc.vectrum.code.ui.BottomSheet | |
| 43 | + import cc.vectrum.code.ui.Field | |
| 44 | + import cc.vectrum.code.ui.Lucide | |
| 45 | + import cc.vectrum.code.ui.LucideIcon | |
| 46 | + import cc.vectrum.code.ui.MenuItem | |
| 47 | + import cc.vectrum.code.ui.MenuLabel | |
| 48 | + import cc.vectrum.code.ui.MenuRow | |
| 49 | + import cc.vectrum.code.ui.MenuSeparator | |
| 50 | + | |
| 51 | + // The Add project flow of the desktop's command palette (src/ui/workspace/ | |
| 52 | + // palette.rs) as a sheet: the environment, then where the project comes | |
| 53 | + // from — a folder there, browsed from a path field (src/ui/folder_browser.rs), | |
| 54 | + // a new repository, or a clone — and the host adds it. | |
| 55 | + | |
| 56 | + /** A forge to clone from, with the desktop's serde spelling and its logo. */ | |
| 57 | + private class Forge( | |
| 58 | + val key: String, | |
| 59 | + val label: String, | |
| 60 | + val placeholder: String, | |
| 61 | + val description: String, | |
| 62 | + val logo: Int?, | |
| 63 | + val color: Color?, | |
| 64 | + ) | |
| 65 | + | |
| 66 | + private val Forges = listOf( | |
| 67 | + Forge("git_hub", "GitHub repository", "owner/repo", "Clone a GitHub owner/repo", null, null), | |
| 68 | + Forge("git_lab", "GitLab repository", "owner/repo", "Clone a GitLab group/repo", R.drawable.brand_gitlab, Color(0xfffc6d26)), | |
| 69 | + Forge( | |
| 70 | + "azure_dev_ops", "Azure DevOps repository", "organization/project/repo", | |
| 71 | + "Clone Azure DevOps project/repository", R.drawable.brand_azure_devops, Color(0xff2f8fe8), | |
| 72 | + ), | |
| 73 | + Forge("bitbucket", "Bitbucket repository", "workspace/repo", "Clone Bitbucket workspace/repository", R.drawable.brand_bitbucket, Color(0xff2684ff)), | |
| 74 | + Forge("forgejo", "Forgejo / Gitea repository", "owner/repo", "Clone Forgejo / Gitea owner/repo", R.drawable.brand_forgejo, Color(0xfffb923c)), | |
| 75 | + ) | |
| 76 | + | |
| 77 | + private sealed interface Step { | |
| 78 | + data object Hosts : Step | |
| 79 | + | |
| 80 | + data object Sources : Step | |
| 81 | + | |
| 82 | + data object Folders : Step | |
| 83 | + | |
| 84 | + data object NewProject : Step | |
| 85 | + | |
| 86 | + data class Clone(val forge: Forge?) : Step | |
| 87 | + } | |
| 88 | + | |
| 89 | + /** The most folders a listing shows at once; typing narrows them down. */ | |
| 90 | + private const val MaxRows = 300 | |
| 91 | + | |
| 92 | + /** Adds a project on a host: a folder there, a new one, or a clone. [host] skips picking one. */ | |
| 93 | + @Composable | |
| 94 | + fun AddProjectSheet(app: AppState, host: HostState?, open: Boolean, onDismiss: () -> Unit) { | |
| 95 | + val p = palette | |
| 96 | + val first = if (host == null && app.hosts.size > 1) Step.Hosts else Step.Sources | |
| 97 | + var stack by remember(open) { mutableStateOf(listOf(first)) } | |
| 98 | + var picked by remember(open) { mutableStateOf(host ?: app.hosts.singleOrNull()) } | |
| 99 | + val step = stack.last() | |
| 100 | + val push = { next: Step -> stack = stack + next } | |
| 101 | + val pop = { stack = stack.dropLast(1) } | |
| 102 | + | |
| 103 | + val add = { source: ProjectSource -> | |
| 104 | + val target = picked | |
| 105 | + if (target != null) { | |
| 106 | + val clone = when (source) { | |
| 107 | + is ProjectSource.Url -> source.url | |
| 108 | + is ProjectSource.Forge -> source.repo | |
| 109 | + is ProjectSource.Folder, is ProjectSource.New -> null | |
| 110 | + } | |
| 111 | + if (clone != null) app.showNotice("Cloning ${repoName(clone) ?: "the repository"} on ${target.name}…") | |
| 112 | + if (!target.send(ClientMessage.AddProject(source))) app.showNotice("${target.name} is offline.", error = true) | |
| 113 | + } | |
| 114 | + onDismiss() | |
| 115 | + } | |
| 116 | + | |
| 117 | + BottomSheet(open, onDismiss) { | |
| 118 | + if (open) BackHandler(enabled = stack.size > 1) { pop() } | |
| 119 | + val title = when (step) { | |
| 120 | + Step.Hosts -> "Environments" | |
| 121 | + Step.Sources -> picked?.let { "Add project on ${it.name}" } ?: "Add project" | |
| 122 | + Step.Folders -> "Directories" | |
| 123 | + Step.NewProject -> "New project" | |
| 124 | + is Step.Clone -> "Clone" | |
| 125 | + } | |
| 126 | + Row( | |
| 127 | + Modifier.fillMaxWidth().defaultMinSize(minHeight = 32.dp), | |
| 128 | + horizontalArrangement = Arrangement.spacedBy(4.dp), | |
| 129 | + verticalAlignment = Alignment.CenterVertically, | |
| 130 | + ) { | |
| 131 | + if (stack.size > 1) Cap(icon = Lucide.ChevronLeft, kind = CapKind.Ghost, size = CapSize.Compact, onClick = pop) | |
| 132 | + MenuLabel(title) | |
| 133 | + } | |
| 134 | + val target = picked | |
| 135 | + when { | |
| 136 | + app.hosts.isEmpty() || (target == null && step != Step.Hosts) -> { | |
| 137 | + Txt( | |
| 138 | + "Pair with a computer under Settings → Connections to add its projects here.", | |
| 139 | + Modifier.padding(horizontal = 8.dp, vertical = 12.dp), | |
| 140 | + color = p.ink3, | |
| 141 | + size = 13.sp, | |
| 142 | + lineHeight = 19.sp, | |
| 143 | + ) | |
| 144 | + } | |
| 145 | + step == Step.Hosts -> HostList(app) { | |
| 146 | + picked = it | |
| 147 | + push(Step.Sources) | |
| 148 | + } | |
| 149 | + target == null -> {} | |
| 150 | + step == Step.Sources -> Sources(target, push) | |
| 151 | + step == Step.Folders -> FolderBrowser(target) { add(ProjectSource.Folder(it)) } | |
| 152 | + step == Step.NewProject -> NewProject(target) { add(ProjectSource.New(it)) } | |
| 153 | + step is Step.Clone -> CloneRepo(target, step.forge) { input -> | |
| 154 | + add(if (step.forge == null) ProjectSource.Url(input) else ProjectSource.Forge(step.forge.key, input)) | |
| 155 | + } | |
| 156 | + } | |
| 157 | + } | |
| 158 | + } | |
| 159 | + | |
| 160 | + @Composable | |
| 161 | + private fun HostList(app: AppState, onPick: (HostState) -> Unit) { | |
| 162 | + for (host in app.hosts.toList()) { | |
| 163 | + val detail = when (host.status) { | |
| 164 | + is ConnectionStatus.Connected -> host.saved.address | |
| 165 | + ConnectionStatus.Connecting -> "Connecting…" | |
| 166 | + else -> "Not connected" | |
| 167 | + } | |
| 168 | + MenuRow(host.name, icon = Lucide.Laptop, detail = detail, enabled = host.connected) { onPick(host) } | |
| 169 | + } | |
| 170 | + } | |
| 171 | + | |
| 172 | + @Composable | |
| 173 | + private fun Sources(host: HostState, push: (Step) -> Unit) { | |
| 174 | + val p = palette | |
| 175 | + MenuRow("New project", icon = Lucide.FolderPlus, detail = "Start a new Git repository from a name") { push(Step.NewProject) } | |
| 176 | + MenuRow("Local folder", icon = Lucide.FolderOpen, detail = "Browse a folder on ${host.name}") { push(Step.Folders) } | |
| 177 | + MenuRow("Git URL", icon = Lucide.Link, detail = "Clone from a remote URL") { push(Step.Clone(null)) } | |
| 178 | + MenuSeparator() | |
| 179 | + for (forge in Forges) { | |
| 180 | + MenuItem(onClick = { push(Step.Clone(forge)) }) { | |
| 181 | + ForgeLogo(forge) | |
| 182 | + Column(Modifier.weight(1f)) { | |
| 183 | + Txt(forge.label, color = p.ink, size = 13.5.sp, maxLines = 1) | |
| 184 | + Txt(forge.description, color = p.ink3.copy(alpha = 0.8f), size = 12.sp, maxLines = 2) | |
| 185 | + } | |
| 186 | + } | |
| 187 | + } | |
| 188 | + } | |
| 189 | + | |
| 190 | + @Composable | |
| 191 | + private fun ForgeLogo(forge: Forge?, size: androidx.compose.ui.unit.Dp = 15.dp) { | |
| 192 | + val p = palette | |
| 193 | + when { | |
| 194 | + forge == null -> LucideIcon(Lucide.Link, size = size, tint = p.icon) | |
| 195 | + forge.logo == null -> LucideIcon(Lucide.Github, size = size, tint = p.icon) | |
| 196 | + else -> Image( | |
| 197 | + painterResource(forge.logo), | |
| 198 | + contentDescription = null, | |
| 199 | + modifier = Modifier.size(size), | |
| 200 | + colorFilter = ColorFilter.tint(forge.color ?: p.icon), | |
| 201 | + ) | |
| 202 | + } | |
| 203 | + } | |
| 204 | + | |
| 205 | + /** A listing the host sent, or why there's none. */ | |
| 206 | + private sealed interface Listing { | |
| 207 | + data object Loading : Listing | |
| 208 | + | |
| 209 | + data class Ready(val folders: List<Folder>, val truncated: Boolean) : Listing | |
| 210 | + | |
| 211 | + data class Failed(val error: String) : Listing | |
| 212 | + } | |
| 213 | + | |
| 214 | + @Composable | |
| 215 | + private fun FolderBrowser(host: HostState, onAdd: (String) -> Unit) { | |
| 216 | + val p = palette | |
| 217 | + var field by remember { mutableStateOf(TextFieldValue("~/", TextRange(2))) } | |
| 218 | + val (dir, filter) = splitField(field.text) | |
| 219 | + var listing by remember { mutableStateOf<Listing>(Listing.Loading) } | |
| 220 | + LaunchedEffect(dir) { | |
| 221 | + listing = Listing.Loading | |
| 222 | + val reply = host.listFolders(dir) | |
| 223 | + listing = if (reply.error != null) Listing.Failed(reply.error) else Listing.Ready(reply.folders, reply.truncated) | |
| 224 | + } | |
| 225 | + val folders = (listing as? Listing.Ready)?.folders ?: emptyList() | |
| 226 | + val matches = filterNames(folders.map { it.name }, filter).map { folders[it] } | |
| 227 | + val namesFolder = listing is Listing.Ready && (filter.isEmpty() || folders.any { it.name == filter }) | |
| 228 | + val go = { text: String -> field = TextFieldValue(text, TextRange(text.length)) } | |
| 229 | + | |
| 230 | + Row( | |
| 231 | + Modifier.padding(horizontal = 4.dp, vertical = 2.dp), | |
| 232 | + horizontalArrangement = Arrangement.spacedBy(6.dp), | |
| 233 | + verticalAlignment = Alignment.CenterVertically, | |
| 234 | + ) { | |
| 235 | + Box(Modifier.weight(1f)) { Field(field, { field = it }, placeholder = "~/", leading = Lucide.Folder, mono = true) } | |
| 236 | + val parent = parentField(dir) | |
| 237 | + Cap(icon = Lucide.FolderUp, kind = CapKind.Ghost, size = CapSize.Regular, enabled = parent != null) { parent?.let(go) } | |
| 238 | + } | |
| 239 | + val message = when (val state = listing) { | |
| 240 | + Listing.Loading -> "Loading…" | |
| 241 | + is Listing.Failed -> state.error | |
| 242 | + is Listing.Ready -> when { | |
| 243 | + state.truncated -> "Showing the first ${state.folders.size} folders. Type to narrow them down." | |
| 244 | + state.folders.isEmpty() -> "No folders here." | |
| 245 | + matches.isEmpty() -> "No folders match." | |
| 246 | + matches.size > MaxRows -> "Showing the first $MaxRows folders. Type to narrow them down." | |
| 247 | + else -> null | |
| 248 | + } | |
| 249 | + } | |
| 250 | + if (message != null) { | |
| 251 | + Txt(message, Modifier.padding(horizontal = 12.dp, vertical = 8.dp), color = p.ink3, size = 12.5.sp, lineHeight = 18.sp) | |
| 252 | + } | |
| 253 | + for (folder in matches.take(MaxRows)) { | |
| 254 | + MenuItem(onClick = { go("$dir${folder.name}/") }) { | |
| 255 | + LucideIcon(Lucide.Folder, size = 15.dp, tint = p.icon) | |
| 256 | + Txt(folder.name, Modifier.weight(1f), color = p.ink, size = 13.5.sp, maxLines = 1) | |
| 257 | + if (folder.git) Tag("git") | |
| 258 | + } | |
| 259 | + } | |
| 260 | + val path = field.text.trim() | |
| 261 | + val name = path.trimEnd('/').substringAfterLast('/').ifEmpty { path } | |
| 262 | + Box(Modifier.fillMaxWidth().padding(start = 4.dp, end = 4.dp, top = 8.dp, bottom = 4.dp)) { | |
| 263 | + Cap( | |
| 264 | + Modifier.fillMaxWidth(), | |
| 265 | + label = if (namesFolder && path.isNotEmpty()) "Add $name" else "Add", | |
| 266 | + icon = Lucide.FolderPlus, | |
| 267 | + kind = CapKind.Primary, | |
| 268 | + enabled = namesFolder && path.isNotEmpty(), | |
| 269 | + ) { onAdd(path) } | |
| 270 | + } | |
| 271 | + } | |
| 272 | + | |
| 273 | + @Composable | |
| 274 | + private fun NewProject(host: HostState, onCreate: (String) -> Unit) { | |
| 275 | + var name by remember { mutableStateOf(TextFieldValue("")) } | |
| 276 | + val trimmed = name.text.trim() | |
| 277 | + val problem = checkName(trimmed) | |
| 278 | + val base = "the base folder on ${host.name}" | |
| 279 | + Box(Modifier.padding(horizontal = 4.dp, vertical = 2.dp)) { | |
| 280 | + Field(name, { name = it }, placeholder = "Project name", leading = Lucide.FolderPlus) | |
| 281 | + } | |
| 282 | + MenuRow( | |
| 283 | + problem ?: "Create $trimmed", | |
| 284 | + icon = Lucide.FolderPlus, | |
| 285 | + detail = if (problem != null) "It goes in $base" else "In $base · a new Git repository", | |
| 286 | + enabled = problem == null, | |
| 287 | + ) { onCreate(trimmed) } | |
| 288 | + } | |
| 6 | 289 | |
| 7 | - /** Adds a project on a host: a folder there, a new one, or a clone. Placeholder until it's built. */ | |
| 8 | 290 | @Composable |
| 9 | - fun AddProjectSheet(app: AppState, host: HostState?, open: Boolean, onDismiss: () -> Unit) {} | |
| 291 | + private fun CloneRepo(host: HostState, forge: Forge?, onClone: (String) -> Unit) { | |
| 292 | + val p = palette | |
| 293 | + var input by remember { mutableStateOf(TextFieldValue("")) } | |
| 294 | + val text = input.text.trim() | |
| 295 | + val name = repoName(text) | |
| 296 | + val base = "the base folder on ${host.name}" | |
| 297 | + Box(Modifier.padding(horizontal = 4.dp, vertical = 2.dp)) { | |
| 298 | + Field( | |
| 299 | + input, | |
| 300 | + { input = it }, | |
| 301 | + placeholder = forge?.placeholder ?: "https://github.com/owner/repo.git", | |
| 302 | + leading = if (forge == null) Lucide.Link else null, | |
| 303 | + mono = true, | |
| 304 | + ) | |
| 305 | + } | |
| 306 | + val (title, detail) = when { | |
| 307 | + text.isEmpty() -> (if (forge != null) "Type ${forge.placeholder}" else "Paste a Git URL") to "It clones into $base" | |
| 308 | + name == null -> "That doesn't look like a repository." to null | |
| 309 | + else -> "Clone $name" to "Into $base" | |
| 310 | + } | |
| 311 | + MenuItem(enabled = text.isNotEmpty() && name != null, onClick = { onClone(text) }) { | |
| 312 | + ForgeLogo(forge) | |
| 313 | + Column(Modifier.weight(1f)) { | |
| 314 | + Txt(title, color = p.ink, size = 13.5.sp, weight = if (name != null) FontWeight.Medium else FontWeight.Normal, maxLines = 2) | |
| 315 | + if (detail != null) Txt(detail, color = p.ink3.copy(alpha = 0.8f), size = 12.sp, maxLines = 2) | |
| 316 | + } | |
| 317 | + } | |
| 318 | + } | |
| @@ -0,0 +1,58 @@ | ||
| 1 | + package cc.vectrum.code.ui.pair | |
| 2 | + | |
| 3 | + // What the Add project browser does with its path field, as the desktop's | |
| 4 | + // (src/ui/folder_browser.rs, src/projects.rs): the field names a folder to | |
| 5 | + // list and filters it, like a shell. | |
| 6 | + | |
| 7 | + /** | |
| 8 | + * Splits the field into the folder to list and the partial name after it: | |
| 9 | + * `~/Work/vec` is `~/Work/` filtered by `vec`. A bare `~` is the home folder. | |
| 10 | + */ | |
| 11 | + fun splitField(text: String): Pair<String, String> { | |
| 12 | + if (text == "~") return "~/" to "" | |
| 13 | + val ix = text.lastIndexOf('/') | |
| 14 | + return if (ix < 0) "" to text else text.substring(0, ix + 1) to text.substring(ix + 1) | |
| 15 | + } | |
| 16 | + | |
| 17 | + /** | |
| 18 | + * The indexes of the names matching [filter], case-insensitively: those | |
| 19 | + * starting with it first, then those containing it. Dot-folders only show | |
| 20 | + * when the filter starts with a dot. | |
| 21 | + */ | |
| 22 | + fun filterNames(names: List<String>, filter: String): List<Int> { | |
| 23 | + val needle = filter.lowercase() | |
| 24 | + val starts = mutableListOf<Int>() | |
| 25 | + val contains = mutableListOf<Int>() | |
| 26 | + names.forEachIndexed { ix, name -> | |
| 27 | + if (name.startsWith('.') && !filter.startsWith('.')) return@forEachIndexed | |
| 28 | + val lower = name.lowercase() | |
| 29 | + if (lower.startsWith(needle)) starts += ix else if (lower.contains(needle)) contains += ix | |
| 30 | + } | |
| 31 | + return starts + contains | |
| 32 | + } | |
| 33 | + | |
| 34 | + /** The folder above the one the field lists, or `null` at `~/` and `/`. */ | |
| 35 | + fun parentField(dir: String): String? { | |
| 36 | + val trimmed = dir.trimEnd('/') | |
| 37 | + if (trimmed.isEmpty() || trimmed == "~") return null | |
| 38 | + val ix = trimmed.lastIndexOf('/') | |
| 39 | + return if (ix < 0) null else trimmed.substring(0, ix + 1) | |
| 40 | + } | |
| 41 | + | |
| 42 | + /** The folder a repository clones into: the URL's last part without `.git`. */ | |
| 43 | + fun repoName(url: String): String? { | |
| 44 | + val trimmed = url.trim().trimEnd('/') | |
| 45 | + val last = trimmed.split('/', ':').last() | |
| 46 | + val name = last.removeSuffix(".git") | |
| 47 | + return name.takeIf { it.isNotEmpty() && it != "." && it != ".." } | |
| 48 | + } | |
| 49 | + | |
| 50 | + /** Why a new project's name won't do, if it won't. */ | |
| 51 | + fun checkName(name: String): String? { | |
| 52 | + val trimmed = name.trim() | |
| 53 | + return when { | |
| 54 | + trimmed.isEmpty() -> "Type a name for the project." | |
| 55 | + trimmed == "." || trimmed == ".." || '/' in trimmed || '\\' in trimmed -> "A project name can't contain slashes." | |
| 56 | + else -> null | |
| 57 | + } | |
| 58 | + } | |
| @@ -1,19 +1,269 @@ | ||
| 1 | 1 | package cc.vectrum.code.ui.pair |
| 2 | 2 | |
| 3 | + import androidx.compose.foundation.Image | |
| 4 | + import androidx.compose.foundation.layout.Arrangement | |
| 3 | 5 | import androidx.compose.foundation.layout.Box |
| 6 | + import androidx.compose.foundation.layout.Column | |
| 7 | + import androidx.compose.foundation.layout.Row | |
| 8 | + import androidx.compose.foundation.layout.Spacer | |
| 9 | + import androidx.compose.foundation.layout.fillMaxSize | |
| 10 | + import androidx.compose.foundation.layout.fillMaxWidth | |
| 11 | + import androidx.compose.foundation.layout.height | |
| 12 | + import androidx.compose.foundation.layout.imePadding | |
| 4 | 13 | import androidx.compose.foundation.layout.padding |
| 14 | + import androidx.compose.foundation.layout.size | |
| 15 | + import androidx.compose.foundation.layout.widthIn | |
| 16 | + import androidx.compose.foundation.rememberScrollState | |
| 17 | + import androidx.compose.foundation.shape.RoundedCornerShape | |
| 18 | + import androidx.compose.foundation.verticalScroll | |
| 5 | 19 | import androidx.compose.runtime.Composable |
| 20 | + import androidx.compose.runtime.DisposableEffect | |
| 21 | + import androidx.compose.runtime.LaunchedEffect | |
| 22 | + import androidx.compose.runtime.getValue | |
| 23 | + import androidx.compose.runtime.mutableStateOf | |
| 24 | + import androidx.compose.runtime.remember | |
| 25 | + import androidx.compose.runtime.setValue | |
| 26 | + import androidx.compose.ui.Alignment | |
| 6 | 27 | import androidx.compose.ui.Modifier |
| 28 | + import androidx.compose.ui.graphics.ColorFilter | |
| 29 | + import androidx.compose.ui.platform.LocalConfiguration | |
| 30 | + import androidx.compose.ui.platform.LocalContext | |
| 31 | + import androidx.compose.ui.res.painterResource | |
| 32 | + import androidx.compose.ui.text.font.FontWeight | |
| 33 | + import androidx.compose.ui.text.input.TextFieldValue | |
| 34 | + import androidx.compose.ui.text.style.TextAlign | |
| 35 | + import androidx.compose.ui.text.TextStyle | |
| 7 | 36 | import androidx.compose.ui.unit.dp |
| 37 | + import androidx.compose.ui.unit.sp | |
| 38 | + import cc.vectrum.code.R | |
| 8 | 39 | import cc.vectrum.code.data.AppState |
| 9 | - import cc.vectrum.code.data.OpenThread | |
| 10 | - import cc.vectrum.code.data.ThreadRef | |
| 40 | + import cc.vectrum.code.data.HostState | |
| 41 | + import cc.vectrum.code.net.ConnectionStatus | |
| 42 | + import cc.vectrum.code.net.parsePairing | |
| 43 | + import cc.vectrum.code.net.withPort | |
| 44 | + import cc.vectrum.code.theme.Cap | |
| 45 | + import cc.vectrum.code.theme.CapKind | |
| 46 | + import cc.vectrum.code.theme.CapSize | |
| 11 | 47 | import cc.vectrum.code.theme.Engraved |
| 48 | + import cc.vectrum.code.theme.Keycap | |
| 49 | + import cc.vectrum.code.theme.Led | |
| 50 | + import cc.vectrum.code.theme.Rule | |
| 51 | + import cc.vectrum.code.theme.StatusLed | |
| 52 | + import cc.vectrum.code.theme.Txt | |
| 53 | + import cc.vectrum.code.theme.palette | |
| 54 | + import cc.vectrum.code.theme.raised | |
| 55 | + import cc.vectrum.code.ui.Field | |
| 56 | + import cc.vectrum.code.ui.Lucide | |
| 12 | 57 | import cc.vectrum.code.ui.Nav |
| 13 | 58 | import cc.vectrum.code.ui.Screen |
| 59 | + import kotlinx.coroutines.delay | |
| 60 | + | |
| 61 | + // Pairing with a computer, the phone's welcome: the desktop shows a pairing | |
| 62 | + // code (`address:port/key`) and its QR code under Settings → Connections | |
| 63 | + // once Allow remote connections is on. Typed, pasted or scanned, it's saved | |
| 64 | + // as a host here and the connection's status shows until it's up. | |
| 65 | + | |
| 66 | + /** The host being paired, kept while the sheet moves from Home to the Pair screen. */ | |
| 67 | + private object Pairing { | |
| 68 | + var host by mutableStateOf<String?>(null) | |
| 69 | + } | |
| 14 | 70 | |
| 15 | - // Placeholder until it's built. | |
| 16 | 71 | @Composable |
| 17 | 72 | fun PairScreen(app: AppState, nav: Nav) { |
| 18 | - Box(Modifier.padding(16.dp)) { Engraved("Pair") } | |
| 73 | + val p = palette | |
| 74 | + val context = LocalContext.current | |
| 75 | + var code by remember { mutableStateOf(TextFieldValue("")) } | |
| 76 | + var key by remember { mutableStateOf(TextFieldValue("")) } | |
| 77 | + var error by remember { mutableStateOf<String?>(null) } | |
| 78 | + val host = Pairing.host?.let { app.host(it) } | |
| 79 | + val (address, codeKey) = parsePairing(code.text) | |
| 80 | + val needsKey = address.isNotBlank() && codeKey == null | |
| 81 | + val onPairScreen = nav.screen == Screen.Pair | |
| 82 | + | |
| 83 | + // The pairing is forgotten once this screen is left for another. | |
| 84 | + DisposableEffect(Unit) { onDispose { if (nav.screen != Screen.Pair) Pairing.host = null } } | |
| 85 | + | |
| 86 | + fun connect(text: String = code.text, typedKey: String = key.text) { | |
| 87 | + val (addr, fromCode) = parsePairing(text) | |
| 88 | + val secret = typedKey.trim().ifEmpty { null } ?: fromCode | |
| 89 | + if (addr.isBlank() || secret == null) { | |
| 90 | + error = "Enter the other device's pairing code, or its address and key." | |
| 91 | + return | |
| 92 | + } | |
| 93 | + error = null | |
| 94 | + // On Home the screen goes as soon as there's a host; the status shows on the Pair screen. | |
| 95 | + if (!onPairScreen) nav.go(Screen.Pair) | |
| 96 | + Pairing.host = app.addHost(withPort(addr), secret).saved.id | |
| 97 | + } | |
| 98 | + | |
| 99 | + LaunchedEffect(host?.status) { | |
| 100 | + if (host?.status is ConnectionStatus.Connected) { | |
| 101 | + delay(700) | |
| 102 | + Pairing.host = null | |
| 103 | + // Back to the settings it was opened from, or to a new thread. | |
| 104 | + if (app.hosts.size > 1 && onPairScreen) { | |
| 105 | + if (!nav.back()) nav.home() | |
| 106 | + } else { | |
| 107 | + nav.home() | |
| 108 | + } | |
| 109 | + } | |
| 110 | + } | |
| 111 | + | |
| 112 | + Column(Modifier.fillMaxSize()) { | |
| 113 | + TopBar(nav, onPairScreen) | |
| 114 | + Box( | |
| 115 | + Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()).imePadding(), | |
| 116 | + contentAlignment = Alignment.TopCenter, | |
| 117 | + ) { | |
| 118 | + Column( | |
| 119 | + Modifier.widthIn(max = 440.dp).fillMaxWidth().padding(start = 20.dp, end = 20.dp, top = 16.dp, bottom = 40.dp), | |
| 120 | + horizontalAlignment = Alignment.CenterHorizontally, | |
| 121 | + verticalArrangement = Arrangement.spacedBy(14.dp), | |
| 122 | + ) { | |
| 123 | + Image( | |
| 124 | + painterResource(R.drawable.vectrum_logo), | |
| 125 | + contentDescription = null, | |
| 126 | + modifier = Modifier.size(32.dp), | |
| 127 | + colorFilter = ColorFilter.tint(p.ink), | |
| 128 | + ) | |
| 129 | + Engraved("Pair with a computer") | |
| 130 | + Txt( | |
| 131 | + "Work on your computer's threads from here.", | |
| 132 | + color = p.ink, | |
| 133 | + size = 22.sp, | |
| 134 | + weight = FontWeight.SemiBold, | |
| 135 | + lineHeight = 28.sp, | |
| 136 | + style = TextStyle(textAlign = TextAlign.Center), | |
| 137 | + ) | |
| 138 | + Txt( | |
| 139 | + "This phone opens the projects and threads of Vectrum Code on your computer, with its agents and files.", | |
| 140 | + color = p.ink2, | |
| 141 | + size = 13.5.sp, | |
| 142 | + lineHeight = 20.sp, | |
| 143 | + style = TextStyle(textAlign = TextAlign.Center), | |
| 144 | + ) | |
| 145 | + Spacer(Modifier.height(4.dp)) | |
| 146 | + Steps() | |
| 147 | + Spacer(Modifier.height(4.dp)) | |
| 148 | + Field( | |
| 149 | + code, | |
| 150 | + { | |
| 151 | + code = it | |
| 152 | + error = null | |
| 153 | + }, | |
| 154 | + placeholder = "Pairing code, or 192.168.2.10", | |
| 155 | + leading = Lucide.Link2, | |
| 156 | + mono = true, | |
| 157 | + ) | |
| 158 | + if (needsKey) { | |
| 159 | + Field(key, { key = it }, placeholder = "Key", leading = Lucide.Lock, mono = true) | |
| 160 | + Txt( | |
| 161 | + "The key is under Settings → Connections on the computer, next to the pairing code.", | |
| 162 | + Modifier.fillMaxWidth().padding(horizontal = 4.dp), | |
| 163 | + color = p.ink3, | |
| 164 | + size = 12.sp, | |
| 165 | + lineHeight = 17.sp, | |
| 166 | + ) | |
| 167 | + } | |
| 168 | + if (error != null) { | |
| 169 | + Txt(error!!, Modifier.fillMaxWidth().padding(horizontal = 4.dp), color = p.red, size = 12.5.sp, lineHeight = 18.sp) | |
| 170 | + } | |
| 171 | + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { | |
| 172 | + Cap(Modifier.weight(1f), label = "Scan QR code", icon = Lucide.ScanLine) { | |
| 173 | + scanQrCode( | |
| 174 | + context, | |
| 175 | + onCode = { text -> | |
| 176 | + code = TextFieldValue(text) | |
| 177 | + error = null | |
| 178 | + if (parsePairing(text).second != null) connect(text, "") | |
| 179 | + }, | |
| 180 | + onError = { error = it }, | |
| 181 | + ) | |
| 182 | + } | |
| 183 | + Cap( | |
| 184 | + Modifier.weight(1f), | |
| 185 | + label = "Connect", | |
| 186 | + icon = Lucide.Plug, | |
| 187 | + kind = CapKind.Primary, | |
| 188 | + enabled = address.isNotBlank() && (!needsKey || key.text.isNotBlank()), | |
| 189 | + ) { connect() } | |
| 190 | + } | |
| 191 | + if (host != null) PairStatus(host) | |
| 192 | + Txt( | |
| 193 | + "Connections aren't encrypted. Pair on networks you trust, or over a VPN such as Tailscale or WireGuard.", | |
| 194 | + Modifier.padding(top = 6.dp), | |
| 195 | + color = p.ink3, | |
| 196 | + size = 12.sp, | |
| 197 | + lineHeight = 18.sp, | |
| 198 | + style = TextStyle(textAlign = TextAlign.Center), | |
| 199 | + ) | |
| 200 | + } | |
| 201 | + } | |
| 202 | + } | |
| 203 | + } | |
| 204 | + | |
| 205 | + /** Back to where the Pair screen was opened from, or the sidebar on Home. */ | |
| 206 | + @Composable | |
| 207 | + private fun TopBar(nav: Nav, onPairScreen: Boolean) { | |
| 208 | + val drawer = LocalConfiguration.current.screenWidthDp < 840 | |
| 209 | + Row( | |
| 210 | + Modifier.fillMaxWidth().height(52.dp).padding(horizontal = 8.dp), | |
| 211 | + verticalAlignment = Alignment.CenterVertically, | |
| 212 | + ) { | |
| 213 | + when { | |
| 214 | + onPairScreen -> Cap(icon = Lucide.ArrowLeft, kind = CapKind.Ghost, size = CapSize.Compact) { if (!nav.back()) nav.home() } | |
| 215 | + drawer -> Cap(icon = Lucide.PanelLeftOpen, kind = CapKind.Ghost, size = CapSize.Compact) { nav.sidebarOpen = true } | |
| 216 | + } | |
| 217 | + } | |
| 218 | + } | |
| 219 | + | |
| 220 | + /** What to turn on on the computer, step by step. */ | |
| 221 | + @Composable | |
| 222 | + private fun Steps() { | |
| 223 | + val p = palette | |
| 224 | + val steps = listOf( | |
| 225 | + "On your computer, open Vectrum Code and go to Settings → Connections.", | |
| 226 | + "Turn on Allow remote connections.", | |
| 227 | + "Scan the QR code it shows, or copy its pairing code and paste it below.", | |
| 228 | + ) | |
| 229 | + Column(Modifier.fillMaxWidth().raised(p, RoundedCornerShape(18.dp))) { | |
| 230 | + steps.forEachIndexed { ix, step -> | |
| 231 | + Row( | |
| 232 | + Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp), | |
| 233 | + horizontalArrangement = Arrangement.spacedBy(12.dp), | |
| 234 | + verticalAlignment = Alignment.CenterVertically, | |
| 235 | + ) { | |
| 236 | + Keycap("${ix + 1}") | |
| 237 | + Txt(step, Modifier.weight(1f), color = p.ink, size = 13.5.sp, lineHeight = 19.sp) | |
| 238 | + } | |
| 239 | + if (ix + 1 < steps.size) Box(Modifier.padding(horizontal = 16.dp)) { Rule() } | |
| 240 | + } | |
| 241 | + } | |
| 242 | + } | |
| 243 | + | |
| 244 | + /** The new host's connection, as it stands. */ | |
| 245 | + @Composable | |
| 246 | + private fun PairStatus(host: HostState) { | |
| 247 | + val p = palette | |
| 248 | + val (led, title, detail) = when (val status = host.status) { | |
| 249 | + ConnectionStatus.Connecting -> Triple(Led.Waiting, "Connecting…", withPort(host.saved.address)) | |
| 250 | + is ConnectionStatus.Connected -> Triple(Led.Live, "Connected to ${status.host}", "Opening its threads…") | |
| 251 | + is ConnectionStatus.Denied -> Triple(Led.Failed, status.reason, "Check the key, or copy the pairing code again.") | |
| 252 | + is ConnectionStatus.Offline -> Triple( | |
| 253 | + Led.Failed, | |
| 254 | + "Retrying · ${status.reason}", | |
| 255 | + "Make sure Allow remote connections is on, and that both devices are on the same network.", | |
| 256 | + ) | |
| 257 | + } | |
| 258 | + Row( | |
| 259 | + Modifier.fillMaxWidth().raised(p, RoundedCornerShape(14.dp)).padding(horizontal = 14.dp, vertical = 12.dp), | |
| 260 | + horizontalArrangement = Arrangement.spacedBy(10.dp), | |
| 261 | + verticalAlignment = Alignment.CenterVertically, | |
| 262 | + ) { | |
| 263 | + StatusLed(led, pulse = host.status == ConnectionStatus.Connecting) | |
| 264 | + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { | |
| 265 | + Txt(title, color = p.ink, size = 13.5.sp, weight = FontWeight.Medium, maxLines = 2) | |
| 266 | + Txt(detail, color = p.ink3, size = 12.sp, lineHeight = 17.sp) | |
| 267 | + } | |
| 268 | + } | |
| 19 | 269 | } |
| @@ -0,0 +1,35 @@ | ||
| 1 | + package cc.vectrum.code.ui.pair | |
| 2 | + | |
| 3 | + import android.content.Context | |
| 4 | + import com.google.mlkit.vision.barcode.common.Barcode | |
| 5 | + import com.google.mlkit.vision.codescanner.GmsBarcodeScannerOptions | |
| 6 | + import com.google.mlkit.vision.codescanner.GmsBarcodeScanning | |
| 7 | + | |
| 8 | + /** | |
| 9 | + * Scans a QR code with Google's code scanner, which brings its own camera | |
| 10 | + * screen, so the app needs no camera permission. [onCode] gets what the code | |
| 11 | + * says; [onError] why there's no scanner, as on phones without Google Play | |
| 12 | + * services. A cancelled scan calls neither. | |
| 13 | + */ | |
| 14 | + fun scanQrCode(context: Context, onCode: (String) -> Unit, onError: (String) -> Unit) { | |
| 15 | + val unavailable = "The QR code scanner isn't available on this phone. Paste the pairing code instead." | |
| 16 | + try { | |
| 17 | + val options = GmsBarcodeScannerOptions.Builder() | |
| 18 | + .setBarcodeFormats(Barcode.FORMAT_QR_CODE) | |
| 19 | + .enableAutoZoom() | |
| 20 | + .build() | |
| 21 | + GmsBarcodeScanning.getClient(context, options) | |
| 22 | + .startScan() | |
| 23 | + .addOnSuccessListener { barcode -> | |
| 24 | + val text = barcode.rawValue | |
| 25 | + if (text.isNullOrBlank()) onError("That QR code is empty.") else onCode(text) | |
| 26 | + } | |
| 27 | + .addOnFailureListener { error -> | |
| 28 | + // The scanner module downloads on first use; until then it isn't there. | |
| 29 | + val message = error.message.orEmpty() | |
| 30 | + onError(if (message.contains("download", ignoreCase = true)) "The QR code scanner is still downloading. Try again in a moment." else unavailable) | |
| 31 | + } | |
| 32 | + } catch (_: Throwable) { | |
| 33 | + onError(unavailable) | |
| 34 | + } | |
| 35 | + } | |
| @@ -0,0 +1,84 @@ | ||
| 1 | + package cc.vectrum.code.ui.settings | |
| 2 | + | |
| 3 | + import android.content.Context | |
| 4 | + import androidx.compose.runtime.Composable | |
| 5 | + import androidx.compose.ui.platform.LocalContext | |
| 6 | + import androidx.compose.ui.platform.LocalUriHandler | |
| 7 | + import androidx.compose.ui.unit.dp | |
| 8 | + import cc.vectrum.code.data.AppState | |
| 9 | + import cc.vectrum.code.theme.Cap | |
| 10 | + import cc.vectrum.code.theme.CapKind | |
| 11 | + import cc.vectrum.code.theme.CapSize | |
| 12 | + import cc.vectrum.code.theme.StatusLed | |
| 13 | + import cc.vectrum.code.theme.palette | |
| 14 | + import cc.vectrum.code.ui.Lucide | |
| 15 | + import cc.vectrum.code.ui.LucideIcon | |
| 16 | + | |
| 17 | + // About: the desktop keeps it at the end of General, with the open source | |
| 18 | + // licenses a page further; a phone gives it a page: this app's version, each | |
| 19 | + // environment's, and the notices for what the app is built from. | |
| 20 | + | |
| 21 | + /** This app's version name, as the package says it. */ | |
| 22 | + fun appVersion(context: Context): String = | |
| 23 | + runCatching { context.packageManager.getPackageInfo(context.packageName, 0).versionName }.getOrNull() ?: "?" | |
| 24 | + | |
| 25 | + /** A notice on the page: what it is, its license, what it is to the app and where it lives. */ | |
| 26 | + private class Notice(val name: String, val license: String, val bundle: String, val source: String) | |
| 27 | + | |
| 28 | + private val Notices = listOf( | |
| 29 | + Notice("Geist, Geist Mono and Geist Pixel", "SIL Open Font License 1.1", "Fonts", "https://github.com/vercel/geist-font"), | |
| 30 | + Notice("Lucide", "ISC", "Icons", "https://github.com/lucide-icons/lucide"), | |
| 31 | + Notice("Jetpack Compose and AndroidX", "Apache-2.0", "Libraries", "https://developer.android.com/jetpack/androidx"), | |
| 32 | + Notice("Kotlin, kotlinx.coroutines and kotlinx.serialization", "Apache-2.0", "Libraries", "https://github.com/Kotlin"), | |
| 33 | + Notice("OkHttp", "Apache-2.0", "Library", "https://github.com/square/okhttp"), | |
| 34 | + Notice("Google code scanner", "Google Play services terms", "Library", "https://developers.google.com/ml-kit/vision/barcode-scanning/code-scanner"), | |
| 35 | + ) | |
| 36 | + | |
| 37 | + @Composable | |
| 38 | + fun AboutPage(app: AppState) { | |
| 39 | + val p = palette | |
| 40 | + val context = LocalContext.current | |
| 41 | + val uri = LocalUriHandler.current | |
| 42 | + Section("About") { | |
| 43 | + row { | |
| 44 | + SettingRow( | |
| 45 | + "Version", | |
| 46 | + id = "version", | |
| 47 | + icon = { LucideIcon(Lucide.Smartphone, size = 14.dp, tint = p.ink2) }, | |
| 48 | + titleSuffix = { Code(appVersion(context)) }, | |
| 49 | + description = "Current version of Vectrum Code for Android.", | |
| 50 | + ) | |
| 51 | + } | |
| 52 | + } | |
| 53 | + if (app.hosts.isNotEmpty()) { | |
| 54 | + Section("Environments") { | |
| 55 | + for (host in app.hosts.toList()) row { | |
| 56 | + val (led, text) = hostStatus(host) | |
| 57 | + SettingRow( | |
| 58 | + host.name, | |
| 59 | + icon = { LucideIcon(Lucide.Server, size = 14.dp, tint = p.ink2) }, | |
| 60 | + titleSuffix = { if (host.version.isNotBlank()) Code(host.version) }, | |
| 61 | + description = if (host.version.isBlank()) "$text · Its version shows once it connects." else text, | |
| 62 | + inline = true, | |
| 63 | + ) { StatusLed(led) } | |
| 64 | + } | |
| 65 | + } | |
| 66 | + } | |
| 67 | + Section( | |
| 68 | + "Open source licenses", | |
| 69 | + id = "open-source-licenses", | |
| 70 | + description = "Notices for the fonts, icons and libraries Vectrum Code for Android is built from. Geist is licensed under the SIL Open Font License 1.1.", | |
| 71 | + ) { | |
| 72 | + for (notice in Notices) row { | |
| 73 | + SettingRow( | |
| 74 | + notice.name, | |
| 75 | + description = "${notice.license} · ${notice.bundle}", | |
| 76 | + inline = true, | |
| 77 | + ) { | |
| 78 | + Cap(icon = Lucide.ExternalLink, kind = CapKind.Ghost, size = CapSize.Compact) { | |
| 79 | + runCatching { uri.openUri(notice.source) } | |
| 80 | + } | |
| 81 | + } | |
| 82 | + } | |
| 83 | + } | |
| 84 | + } | |
| @@ -0,0 +1,319 @@ | ||
| 1 | + package cc.vectrum.code.ui.settings | |
| 2 | + | |
| 3 | + import androidx.compose.foundation.background | |
| 4 | + import androidx.compose.foundation.border | |
| 5 | + import androidx.compose.foundation.clickable | |
| 6 | + import androidx.compose.foundation.layout.Arrangement | |
| 7 | + import androidx.compose.foundation.layout.Box | |
| 8 | + import androidx.compose.foundation.layout.BoxWithConstraints | |
| 9 | + import androidx.compose.foundation.layout.Column | |
| 10 | + import androidx.compose.foundation.layout.Row | |
| 11 | + import androidx.compose.foundation.layout.Spacer | |
| 12 | + import androidx.compose.foundation.layout.defaultMinSize | |
| 13 | + import androidx.compose.foundation.layout.fillMaxHeight | |
| 14 | + import androidx.compose.foundation.layout.fillMaxSize | |
| 15 | + import androidx.compose.foundation.layout.fillMaxWidth | |
| 16 | + import androidx.compose.foundation.layout.height | |
| 17 | + import androidx.compose.foundation.layout.offset | |
| 18 | + import androidx.compose.foundation.layout.padding | |
| 19 | + import androidx.compose.foundation.layout.requiredSize | |
| 20 | + import androidx.compose.foundation.layout.size | |
| 21 | + import androidx.compose.foundation.layout.width | |
| 22 | + import androidx.compose.foundation.shape.CircleShape | |
| 23 | + import androidx.compose.foundation.shape.RoundedCornerShape | |
| 24 | + import androidx.compose.runtime.Composable | |
| 25 | + import androidx.compose.ui.Alignment | |
| 26 | + import androidx.compose.ui.Modifier | |
| 27 | + import androidx.compose.ui.draw.clip | |
| 28 | + import androidx.compose.ui.draw.dropShadow | |
| 29 | + import androidx.compose.ui.graphics.Color | |
| 30 | + import androidx.compose.ui.graphics.shadow.Shadow | |
| 31 | + import androidx.compose.ui.text.font.FontWeight | |
| 32 | + import androidx.compose.ui.unit.Dp | |
| 33 | + import androidx.compose.ui.unit.dp | |
| 34 | + import androidx.compose.ui.unit.sp | |
| 35 | + import cc.vectrum.code.data.DevicePrefs | |
| 36 | + import cc.vectrum.code.theme.Duo | |
| 37 | + import cc.vectrum.code.theme.Palette | |
| 38 | + import cc.vectrum.code.theme.Tag | |
| 39 | + import cc.vectrum.code.theme.Theme | |
| 40 | + import cc.vectrum.code.theme.Themes | |
| 41 | + import cc.vectrum.code.theme.Txt | |
| 42 | + import cc.vectrum.code.theme.metal | |
| 43 | + import cc.vectrum.code.theme.palette | |
| 44 | + import cc.vectrum.code.theme.raised | |
| 45 | + import cc.vectrum.code.ui.Lucide | |
| 46 | + import cc.vectrum.code.ui.LucideIcon | |
| 47 | + | |
| 48 | + // The Appearance page (src/ui/settings/appearance.rs): Color scheme tiles | |
| 49 | + // (System, Light, Dark), a card per theme family where each swatch makes | |
| 50 | + // that theme the light or the dark one, then the Interface rows. Changes | |
| 51 | + // apply at once: the app reads its palette from these preferences. | |
| 52 | + | |
| 53 | + @Composable | |
| 54 | + fun AppearancePage(prefs: DevicePrefs) { | |
| 55 | + val p = palette | |
| 56 | + Section("Colors & themes", id = "appearance", hideTitle = true) { | |
| 57 | + row { | |
| 58 | + Column(verticalArrangement = Arrangement.spacedBy(24.dp)) { | |
| 59 | + Column(Modifier.targetable("color-scheme", 18.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { | |
| 60 | + PageLabel("Color scheme") | |
| 61 | + SchemeTiles(prefs) | |
| 62 | + } | |
| 63 | + Column(Modifier.targetable("theme", 18.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { | |
| 64 | + Box(Modifier.defaultMinSize(minHeight = 32.dp), contentAlignment = Alignment.CenterStart) { PageLabel("Themes") } | |
| 65 | + ThemeCards(prefs) | |
| 66 | + } | |
| 67 | + } | |
| 68 | + } | |
| 69 | + } | |
| 70 | + | |
| 71 | + Section("Interface", id = "appearance-interface") { | |
| 72 | + row { | |
| 73 | + val colors = prefs.string("diff_colors") | |
| 74 | + val (removed, added) = if (colors == "blue_orange") p.tints[0] to p.blueInk else p.red to p.greenInk | |
| 75 | + SettingRow( | |
| 76 | + "Diff colors", | |
| 77 | + id = "diff-color-scheme", | |
| 78 | + description = "Choose colors for additions and deletions, including change counts.", | |
| 79 | + onReset = if (prefs.changed("diff_colors")) ({ prefs.reset("diff_colors") }) else null, | |
| 80 | + ) { | |
| 81 | + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { | |
| 82 | + Row(horizontalArrangement = Arrangement.spacedBy(3.dp)) { | |
| 83 | + Box(Modifier.size(8.dp).background(removed, CircleShape)) | |
| 84 | + Box(Modifier.size(8.dp).background(added, CircleShape)) | |
| 85 | + } | |
| 86 | + Choice( | |
| 87 | + colors, | |
| 88 | + listOf("red_green" to "Red & green (default)", "blue_orange" to "Blue & orange"), | |
| 89 | + label = if (colors == "blue_orange") "Blue & orange" else "Red & green", | |
| 90 | + ) { prefs.set("diff_colors", it) } | |
| 91 | + } | |
| 92 | + } | |
| 93 | + } | |
| 94 | + row { | |
| 95 | + PrefSwitch( | |
| 96 | + prefs, "composer_context", "Composer context", | |
| 97 | + "Keep branch and worktree controls below the composer after a thread starts.", | |
| 98 | + id = "composer-context", | |
| 99 | + ) | |
| 100 | + } | |
| 101 | + } | |
| 102 | + } | |
| 103 | + | |
| 104 | + /** A label over a group on the page. */ | |
| 105 | + @Composable | |
| 106 | + private fun PageLabel(text: String) { | |
| 107 | + Txt(text, Modifier.padding(horizontal = 16.dp), color = palette.ink2, size = 14.sp) | |
| 108 | + } | |
| 109 | + | |
| 110 | + /** System, Light and Dark, each a miniature of the app in that mode. */ | |
| 111 | + @Composable | |
| 112 | + private fun SchemeTiles(prefs: DevicePrefs) { | |
| 113 | + val p = palette | |
| 114 | + val mode = prefs.themeMode | |
| 115 | + val light = Themes.find(prefs.lightTheme, false) | |
| 116 | + val dark = Themes.find(prefs.darkTheme, true) | |
| 117 | + BoxWithConstraints { | |
| 118 | + val previewHeight = if (maxWidth < 480.dp) 96.dp else 140.dp | |
| 119 | + Row(horizontalArrangement = Arrangement.spacedBy(if (maxWidth < 480.dp) 6.dp else 12.dp)) { | |
| 120 | + for ((value, name) in listOf("system" to "System", "light" to "Light", "dark" to "Dark")) { | |
| 121 | + val selected = value == mode | |
| 122 | + val shape = RoundedCornerShape(18.dp) | |
| 123 | + Column( | |
| 124 | + Modifier | |
| 125 | + .weight(1f) | |
| 126 | + .then(if (selected) Modifier.raised(p, shape) else Modifier) | |
| 127 | + .clip(shape) | |
| 128 | + .clickable { prefs.set("theme_mode", value) } | |
| 129 | + .padding(8.dp), | |
| 130 | + verticalArrangement = Arrangement.spacedBy(8.dp), | |
| 131 | + ) { | |
| 132 | + val previewShape = RoundedCornerShape(14.dp) | |
| 133 | + Box( | |
| 134 | + Modifier | |
| 135 | + .fillMaxWidth() | |
| 136 | + .height(previewHeight) | |
| 137 | + .dropShadow(previewShape, Shadow(radius = 0.dp, color = if (selected) p.greenDeep else p.rule, spread = if (selected) 2.dp else 1.dp)), | |
| 138 | + ) { | |
| 139 | + when (value) { | |
| 140 | + "light" -> ThemePreview(light) | |
| 141 | + "dark" -> ThemePreview(dark) | |
| 142 | + // Light on the left, dark on the right. | |
| 143 | + else -> Row(Modifier.fillMaxSize().clip(previewShape)) { | |
| 144 | + Box(Modifier.weight(1f).fillMaxHeight()) { ThemePreview(light, RoundedCornerShape(topStart = 14.dp, bottomStart = 14.dp)) } | |
| 145 | + Box(Modifier.weight(1f).fillMaxHeight()) { ThemePreview(dark, RoundedCornerShape(topEnd = 14.dp, bottomEnd = 14.dp)) } | |
| 146 | + } | |
| 147 | + } | |
| 148 | + } | |
| 149 | + Row( | |
| 150 | + Modifier.padding(horizontal = 4.dp), | |
| 151 | + horizontalArrangement = Arrangement.spacedBy(8.dp), | |
| 152 | + verticalAlignment = Alignment.CenterVertically, | |
| 153 | + ) { | |
| 154 | + Radio(selected) | |
| 155 | + Txt(name, color = p.ink, size = 12.5.sp, weight = FontWeight.Medium, maxLines = 1) | |
| 156 | + } | |
| 157 | + } | |
| 158 | + } | |
| 159 | + } | |
| 160 | + } | |
| 161 | + } | |
| 162 | + | |
| 163 | + /** A card per theme family: a swatch per variant, the family's name, and which modes it currently owns. */ | |
| 164 | + @Composable | |
| 165 | + private fun ThemeCards(prefs: DevicePrefs) { | |
| 166 | + val lightId = prefs.lightTheme | |
| 167 | + val darkId = prefs.darkTheme | |
| 168 | + BoxWithConstraints { | |
| 169 | + val columns = if (maxWidth >= 600.dp) 3 else 2 | |
| 170 | + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { | |
| 171 | + for (chunk in Themes.families.chunked(columns)) { | |
| 172 | + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { | |
| 173 | + for (family in chunk) { | |
| 174 | + Box(Modifier.weight(1f)) { FamilyCard(family.name, family.themes, lightId, darkId, prefs) } | |
| 175 | + } | |
| 176 | + repeat(columns - chunk.size) { Spacer(Modifier.weight(1f)) } | |
| 177 | + } | |
| 178 | + } | |
| 179 | + } | |
| 180 | + } | |
| 181 | + } | |
| 182 | + | |
| 183 | + @Composable | |
| 184 | + private fun FamilyCard(name: String, members: List<Theme>, lightId: String, darkId: String, prefs: DevicePrefs) { | |
| 185 | + val p = palette | |
| 186 | + val ownsLight = members.any { !it.palette.dark && it.id == lightId } | |
| 187 | + val ownsDark = members.any { it.palette.dark && it.id == darkId } | |
| 188 | + val firstLight = members.firstOrNull { !it.palette.dark } | |
| 189 | + val firstDark = members.firstOrNull { it.palette.dark } | |
| 190 | + Column(Modifier.fillMaxWidth().raised(p, RoundedCornerShape(18.dp))) { | |
| 191 | + BoxWithConstraints(Modifier.fillMaxWidth().height(84.dp), contentAlignment = Alignment.Center) { | |
| 192 | + val count = members.size.coerceAtLeast(1) | |
| 193 | + val gap = 10.dp | |
| 194 | + val size = ((maxWidth - 16.dp - gap * (count - 1)) / count).coerceIn(28.dp, 52.dp) | |
| 195 | + Row(horizontalArrangement = Arrangement.spacedBy(gap), verticalAlignment = Alignment.CenterVertically) { | |
| 196 | + for (theme in members) { | |
| 197 | + val chosen = theme.id == if (theme.palette.dark) darkId else lightId | |
| 198 | + Swatch(theme, chosen, size) { pick(prefs, theme) } | |
| 199 | + } | |
| 200 | + } | |
| 201 | + } | |
| 202 | + Row( | |
| 203 | + Modifier.padding(start = 12.dp, end = 12.dp, bottom = 12.dp, top = 4.dp), | |
| 204 | + horizontalArrangement = Arrangement.spacedBy(6.dp), | |
| 205 | + verticalAlignment = Alignment.CenterVertically, | |
| 206 | + ) { | |
| 207 | + // The family's name uses it for both modes, without switching between them. | |
| 208 | + Txt( | |
| 209 | + name, | |
| 210 | + Modifier | |
| 211 | + .weight(1f) | |
| 212 | + .clickable(interactionSource = null, indication = null) { | |
| 213 | + listOfNotNull(firstLight, firstDark).forEach { assign(prefs, it) } | |
| 214 | + }, | |
| 215 | + color = p.ink, | |
| 216 | + size = 13.5.sp, | |
| 217 | + weight = FontWeight.Medium, | |
| 218 | + maxLines = 1, | |
| 219 | + ) | |
| 220 | + if (ownsLight) Tag("Light") | |
| 221 | + if (ownsDark) Tag("Dark") | |
| 222 | + } | |
| 223 | + } | |
| 224 | + } | |
| 225 | + | |
| 226 | + /** Makes [theme] the light or dark theme, switching to its mode unless the mode follows the system. */ | |
| 227 | + private fun pick(prefs: DevicePrefs, theme: Theme) { | |
| 228 | + assign(prefs, theme) | |
| 229 | + if (prefs.themeMode != "system") prefs.set("theme_mode", if (theme.palette.dark) "dark" else "light") | |
| 230 | + } | |
| 231 | + | |
| 232 | + /** Makes [theme] the light or dark theme without touching the mode. */ | |
| 233 | + private fun assign(prefs: DevicePrefs, theme: Theme) { | |
| 234 | + prefs.set(if (theme.palette.dark) "dark_theme" else "light_theme", theme.id) | |
| 235 | + } | |
| 236 | + | |
| 237 | + /** One theme as a round swatch: its page and sheet, a cap and its accent. Ringed when it's the theme of its mode. */ | |
| 238 | + @Composable | |
| 239 | + private fun Swatch(theme: Theme, chosen: Boolean, size: Dp, onClick: () -> Unit) { | |
| 240 | + val p = palette | |
| 241 | + val c = theme.palette | |
| 242 | + val scale = size / 52.dp | |
| 243 | + Box(Modifier.size(size).clickable(interactionSource = null, indication = null, onClick = onClick)) { | |
| 244 | + if (chosen) { | |
| 245 | + Box(Modifier.align(Alignment.Center).requiredSize(size + 8.dp * scale).border(2.dp, p.greenDeep, CircleShape)) | |
| 246 | + } | |
| 247 | + Box( | |
| 248 | + Modifier.fillMaxSize().metal(CircleShape, Duo(c.sHi, c.page).brush, c.raiseSm), | |
| 249 | + contentAlignment = Alignment.Center, | |
| 250 | + ) { | |
| 251 | + Box(Modifier.size(22.dp * scale).metal(CircleShape, c.cap.brush, c.btn), contentAlignment = Alignment.Center) { | |
| 252 | + Box(Modifier.size(7.dp * scale).background(c.blueInk, CircleShape)) | |
| 253 | + } | |
| 254 | + } | |
| 255 | + Box( | |
| 256 | + Modifier | |
| 257 | + .align(Alignment.BottomEnd) | |
| 258 | + .offset(2.dp, 2.dp) | |
| 259 | + .size(18.dp) | |
| 260 | + .metal(CircleShape, p.cap.brush, p.raiseSm), | |
| 261 | + contentAlignment = Alignment.Center, | |
| 262 | + ) { | |
| 263 | + LucideIcon(if (c.dark) Lucide.Moon else Lucide.Sun, size = 10.dp, tint = p.ink2) | |
| 264 | + } | |
| 265 | + } | |
| 266 | + } | |
| 267 | + | |
| 268 | + /** | |
| 269 | + * A miniature of the app in a theme (themes::preview): the sheet on the | |
| 270 | + * table, a thread list with one thread picked, a code well and a composer. | |
| 271 | + */ | |
| 272 | + @Composable | |
| 273 | + fun ThemePreview(c: Palette, shape: RoundedCornerShape = RoundedCornerShape(14.dp)) { | |
| 274 | + @Composable | |
| 275 | + fun line(width: Dp, color: Color) = Box(Modifier.width(width).height(4.dp).background(color, CircleShape)) | |
| 276 | + Row( | |
| 277 | + Modifier.fillMaxSize().clip(shape).background(c.page).padding(8.dp), | |
| 278 | + horizontalArrangement = Arrangement.spacedBy(8.dp), | |
| 279 | + ) { | |
| 280 | + Column(Modifier.width(30.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { | |
| 281 | + Box(Modifier.metal(RoundedCornerShape(4.dp), c.rowOn.brush, c.raiseSm).padding(3.dp)) { line(20.dp, c.ink2) } | |
| 282 | + Box(Modifier.padding(horizontal = 3.dp)) { line(16.dp, c.ink3.copy(alpha = c.ink3.alpha * 0.7f)) } | |
| 283 | + Box(Modifier.padding(horizontal = 3.dp)) { line(20.dp, c.ink3.copy(alpha = c.ink3.alpha * 0.7f)) } | |
| 284 | + } | |
| 285 | + Column( | |
| 286 | + Modifier | |
| 287 | + .weight(1f) | |
| 288 | + .fillMaxHeight() | |
| 289 | + .metal(RoundedCornerShape(10.dp), Duo(c.sHi, c.s).brush, c.raiseSm) | |
| 290 | + .clip(RoundedCornerShape(10.dp)) | |
| 291 | + .padding(8.dp), | |
| 292 | + verticalArrangement = Arrangement.spacedBy(5.dp), | |
| 293 | + ) { | |
| 294 | + line(40.dp, c.ink.copy(alpha = c.ink.alpha * 0.8f)) | |
| 295 | + line(56.dp, c.ink3.copy(alpha = c.ink3.alpha * 0.7f)) | |
| 296 | + Column( | |
| 297 | + Modifier.metal(RoundedCornerShape(5.dp), Duo(c.wellTop, c.wellBot).brush, c.well).padding(4.dp), | |
| 298 | + verticalArrangement = Arrangement.spacedBy(3.dp), | |
| 299 | + ) { | |
| 300 | + Row(horizontalArrangement = Arrangement.spacedBy(3.dp)) { | |
| 301 | + line(10.dp, c.blueInk) | |
| 302 | + line(18.dp, c.greenInk) | |
| 303 | + } | |
| 304 | + Row(horizontalArrangement = Arrangement.spacedBy(3.dp)) { | |
| 305 | + line(14.dp, c.tints[3]) | |
| 306 | + line(10.dp, c.amberInk) | |
| 307 | + } | |
| 308 | + } | |
| 309 | + Spacer(Modifier.weight(1f)) | |
| 310 | + Row( | |
| 311 | + Modifier.fillMaxWidth().height(16.dp).metal(RoundedCornerShape(6.dp), c.rowOn.brush, c.raiseSm).padding(horizontal = 4.dp), | |
| 312 | + verticalAlignment = Alignment.CenterVertically, | |
| 313 | + ) { | |
| 314 | + Spacer(Modifier.weight(1f)) | |
| 315 | + Box(Modifier.size(8.dp).metal(CircleShape, c.primary.brush, emptyList())) | |
| 316 | + } | |
| 317 | + } | |
| 318 | + } | |
| 319 | + } | |
| @@ -0,0 +1,130 @@ | ||
| 1 | + package cc.vectrum.code.ui.settings | |
| 2 | + | |
| 3 | + import androidx.compose.foundation.ExperimentalFoundationApi | |
| 4 | + import androidx.compose.foundation.combinedClickable | |
| 5 | + import androidx.compose.foundation.layout.Box | |
| 6 | + import androidx.compose.runtime.Composable | |
| 7 | + import androidx.compose.runtime.getValue | |
| 8 | + import androidx.compose.runtime.mutableStateOf | |
| 9 | + import androidx.compose.runtime.remember | |
| 10 | + import androidx.compose.runtime.setValue | |
| 11 | + import androidx.compose.ui.Modifier | |
| 12 | + import androidx.compose.ui.unit.dp | |
| 13 | + import cc.vectrum.code.data.AppState | |
| 14 | + import cc.vectrum.code.data.HostState | |
| 15 | + import cc.vectrum.code.net.ThreadAction | |
| 16 | + import cc.vectrum.code.net.ThreadSummary | |
| 17 | + import cc.vectrum.code.theme.Badge | |
| 18 | + import cc.vectrum.code.theme.Cap | |
| 19 | + import cc.vectrum.code.theme.CapSize | |
| 20 | + import cc.vectrum.code.theme.palette | |
| 21 | + import cc.vectrum.code.ui.ConfirmDialog | |
| 22 | + import cc.vectrum.code.ui.Lucide | |
| 23 | + import cc.vectrum.code.ui.LucideIcon | |
| 24 | + import cc.vectrum.code.ui.MenuRow | |
| 25 | + import cc.vectrum.code.ui.PopupMenu | |
| 26 | + import cc.vectrum.code.util.initials | |
| 27 | + import cc.vectrum.code.util.tintFor | |
| 28 | + | |
| 29 | + // The Archive page (src/ui/settings/archive.rs): settled threads by project, | |
| 30 | + // newest first, each with when it was archived and created, an Unarchive | |
| 31 | + // button, and Unarchive and Delete on a long press. | |
| 32 | + | |
| 33 | + /** A settled thread and the host it's on. */ | |
| 34 | + private class Archived(val host: HostState, val thread: ThreadSummary, val project: String) | |
| 35 | + | |
| 36 | + /** Settled threads across the hosts, newest first; subagents' threads go with their parents. */ | |
| 37 | + fun settledCount(app: AppState): Int = app.hosts.sumOf { host -> host.threads.values.count { it.settled && it.parent == null } } | |
| 38 | + | |
| 39 | + @Composable | |
| 40 | + fun ArchivePage(app: AppState) { | |
| 41 | + val p = palette | |
| 42 | + val threads = app.hosts.flatMap { host -> | |
| 43 | + host.threads.values.filter { it.settled && it.parent == null }.map { thread -> | |
| 44 | + val name = host.projects.find { it.path == thread.project }?.name ?: thread.project.trimEnd('/').substringAfterLast('/') | |
| 45 | + Archived(host, thread, name) | |
| 46 | + } | |
| 47 | + }.sortedByDescending { it.thread.updated } | |
| 48 | + if (threads.isEmpty()) { | |
| 49 | + Section("Archived threads", id = "archive") { | |
| 50 | + row { | |
| 51 | + SettingRow( | |
| 52 | + "No archived threads", | |
| 53 | + icon = { LucideIcon(Lucide.Archive, size = 14.dp, tint = p.ink3) }, | |
| 54 | + description = "Archived threads will appear here.", | |
| 55 | + ) | |
| 56 | + } | |
| 57 | + } | |
| 58 | + return | |
| 59 | + } | |
| 60 | + // One section per project, in the order their newest thread was archived. | |
| 61 | + val several = app.hosts.size > 1 | |
| 62 | + val groups = threads.groupBy { it.host.saved.id to it.thread.project } | |
| 63 | + for ((ix, group) in groups.values.withIndex()) { | |
| 64 | + val name = group.first().project | |
| 65 | + Section( | |
| 66 | + if (several) "$name · ${group.first().host.name}" else name, | |
| 67 | + id = if (ix == 0) "archive" else null, | |
| 68 | + icon = { Badge(initials(name), tintFor(name)) }, | |
| 69 | + ) { | |
| 70 | + for (item in group) row(item.thread.id) { ArchivedRow(app, item) } | |
| 71 | + } | |
| 72 | + } | |
| 73 | + } | |
| 74 | + | |
| 75 | + @OptIn(ExperimentalFoundationApi::class) | |
| 76 | + @Composable | |
| 77 | + private fun ArchivedRow(app: AppState, item: Archived) { | |
| 78 | + var menu by remember { mutableStateOf(false) } | |
| 79 | + var confirming by remember { mutableStateOf(false) } | |
| 80 | + val thread = item.thread | |
| 81 | + val unarchive: () -> Unit = { item.host.act(thread.id, ThreadAction.Unsettle) } | |
| 82 | + val delete: () -> Unit = { | |
| 83 | + if (app.prefs.bool("confirm_delete")) { | |
| 84 | + confirming = true | |
| 85 | + } else { | |
| 86 | + item.host.act(thread.id, ThreadAction.Delete) | |
| 87 | + } | |
| 88 | + } | |
| 89 | + Box(Modifier.combinedClickable(interactionSource = null, indication = null, onLongClick = { menu = true }, onClick = {})) { | |
| 90 | + SettingRow( | |
| 91 | + thread.title.ifBlank { "New thread" }, | |
| 92 | + icon = { ProviderIcon(thread.provider, size = 12.dp) }, | |
| 93 | + description = "Archived ${ago(thread.updated)} · Created ${ago(thread.created)}", | |
| 94 | + inline = true, | |
| 95 | + ) { | |
| 96 | + Cap(label = "Unarchive", icon = Lucide.ArchiveX, size = CapSize.Compact, onClick = unarchive) | |
| 97 | + } | |
| 98 | + PopupMenu(open = menu, onDismiss = { menu = false }) { | |
| 99 | + MenuRow("Unarchive", icon = Lucide.ArchiveX) { | |
| 100 | + menu = false | |
| 101 | + unarchive() | |
| 102 | + } | |
| 103 | + MenuRow("Delete", icon = Lucide.Trash, danger = true) { | |
| 104 | + menu = false | |
| 105 | + delete() | |
| 106 | + } | |
| 107 | + } | |
| 108 | + } | |
| 109 | + if (confirming) { | |
| 110 | + ConfirmDialog( | |
| 111 | + title = "Delete thread?", | |
| 112 | + message = "This deletes the thread and its chat history.", | |
| 113 | + confirm = "Delete", | |
| 114 | + danger = true, | |
| 115 | + onDismiss = { confirming = false }, | |
| 116 | + onConfirm = { item.host.act(thread.id, ThreadAction.Delete) }, | |
| 117 | + ) | |
| 118 | + } | |
| 119 | + } | |
| 120 | + | |
| 121 | + /** `2m ago`, `just now`, as the desktop's settings say it. */ | |
| 122 | + fun ago(millis: Long, now: Long = System.currentTimeMillis()): String { | |
| 123 | + val secs = ((now - millis) / 1_000).coerceAtLeast(0) | |
| 124 | + return when { | |
| 125 | + secs < 60 -> "just now" | |
| 126 | + secs < 3_600 -> "${secs / 60}m ago" | |
| 127 | + secs < 86_400 -> "${secs / 3_600}h ago" | |
| 128 | + else -> "${secs / 86_400}d ago" | |
| 129 | + } | |
| 130 | + } | |
| @@ -0,0 +1,106 @@ | ||
| 1 | + package cc.vectrum.code.ui.settings | |
| 2 | + | |
| 3 | + import androidx.compose.foundation.layout.Arrangement | |
| 4 | + import androidx.compose.foundation.layout.Row | |
| 5 | + import androidx.compose.runtime.Composable | |
| 6 | + import androidx.compose.runtime.getValue | |
| 7 | + import androidx.compose.runtime.mutableStateOf | |
| 8 | + import androidx.compose.runtime.remember | |
| 9 | + import androidx.compose.runtime.setValue | |
| 10 | + import androidx.compose.ui.Alignment | |
| 11 | + import androidx.compose.ui.unit.dp | |
| 12 | + import cc.vectrum.code.data.AppState | |
| 13 | + import cc.vectrum.code.data.HostState | |
| 14 | + import cc.vectrum.code.net.ConnectionStatus | |
| 15 | + import cc.vectrum.code.net.withPort | |
| 16 | + import cc.vectrum.code.theme.Cap | |
| 17 | + import cc.vectrum.code.theme.CapKind | |
| 18 | + import cc.vectrum.code.theme.CapSize | |
| 19 | + import cc.vectrum.code.theme.Led | |
| 20 | + import cc.vectrum.code.theme.StatusLed | |
| 21 | + import cc.vectrum.code.theme.Toggle | |
| 22 | + import cc.vectrum.code.theme.palette | |
| 23 | + import cc.vectrum.code.ui.ConfirmDialog | |
| 24 | + import cc.vectrum.code.ui.Lucide | |
| 25 | + import cc.vectrum.code.ui.LucideIcon | |
| 26 | + import cc.vectrum.code.ui.Nav | |
| 27 | + import cc.vectrum.code.ui.Screen | |
| 28 | + | |
| 29 | + // The Connections page (src/ui/settings/connections.rs): this phone, which | |
| 30 | + // only connects to others, and the environments it's paired with, with "Add | |
| 31 | + // environment" for pairing another. | |
| 32 | + | |
| 33 | + /** How a host's connection stands, as the desktop's environment rows say it. */ | |
| 34 | + fun hostStatus(host: HostState): Pair<Led, String> { | |
| 35 | + if (!host.saved.enabled) return Led.Off to "Off" | |
| 36 | + return when (val status = host.status) { | |
| 37 | + is ConnectionStatus.Connected -> Led.Live to "Connected to ${status.host}" | |
| 38 | + ConnectionStatus.Connecting -> Led.Waiting to "Connecting…" | |
| 39 | + is ConnectionStatus.Offline -> Led.Failed to "Retrying · ${status.reason}" | |
| 40 | + is ConnectionStatus.Denied -> Led.Failed to status.reason | |
| 41 | + } | |
| 42 | + } | |
| 43 | + | |
| 44 | + @Composable | |
| 45 | + fun ConnectionsPage(app: AppState, nav: Nav) { | |
| 46 | + val p = palette | |
| 47 | + Section( | |
| 48 | + "This device", | |
| 49 | + id = "connections-device", | |
| 50 | + icon = { LucideIcon(Lucide.Smartphone, size = 14.dp, tint = p.ink2) }, | |
| 51 | + description = "Connections aren't encrypted. Use them on networks you trust, or over a VPN such as Tailscale or WireGuard.", | |
| 52 | + ) { | |
| 53 | + row { | |
| 54 | + SettingRow( | |
| 55 | + "Remote client", | |
| 56 | + description = "This phone opens the projects and threads of the environments below and works in them, with their agents and files. Other devices can't connect to it; to share a computer, turn on Allow remote connections there.", | |
| 57 | + ) | |
| 58 | + } | |
| 59 | + } | |
| 60 | + | |
| 61 | + Section( | |
| 62 | + "Environments", | |
| 63 | + id = "remote-environments", | |
| 64 | + headerAction = { | |
| 65 | + Cap(label = "Add environment", icon = Lucide.Plus, size = CapSize.Compact) { nav.go(Screen.Pair) } | |
| 66 | + }, | |
| 67 | + ) { | |
| 68 | + if (app.hosts.isEmpty()) row { EmptyRow("No saved remote environments") } | |
| 69 | + for (host in app.hosts.toList()) row(host.saved.id) { HostRow(app, host) } | |
| 70 | + } | |
| 71 | + } | |
| 72 | + | |
| 73 | + @Composable | |
| 74 | + private fun HostRow(app: AppState, host: HostState) { | |
| 75 | + val (led, text) = hostStatus(host) | |
| 76 | + var removing by remember { mutableStateOf(false) } | |
| 77 | + SettingRow( | |
| 78 | + host.name, | |
| 79 | + description = "${withPort(host.saved.address)} · $text", | |
| 80 | + inline = true, | |
| 81 | + titleSuffix = { | |
| 82 | + Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) { | |
| 83 | + StatusLed(led, pulse = host.status == ConnectionStatus.Connecting && host.saved.enabled) | |
| 84 | + if (host.version.isNotBlank()) Code("v${host.version}") | |
| 85 | + } | |
| 86 | + }, | |
| 87 | + ) { | |
| 88 | + Row(horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically) { | |
| 89 | + if (host.saved.enabled && !host.connected) { | |
| 90 | + Cap(icon = Lucide.RefreshCw, kind = CapKind.Ghost, size = CapSize.Compact) { host.retry() } | |
| 91 | + } | |
| 92 | + Toggle(host.saved.enabled) { app.setHostEnabled(host, it) } | |
| 93 | + Cap(icon = Lucide.Trash, kind = CapKind.Ghost, size = CapSize.Compact) { removing = true } | |
| 94 | + } | |
| 95 | + } | |
| 96 | + if (removing) { | |
| 97 | + ConfirmDialog( | |
| 98 | + title = "Remove ${host.name}?", | |
| 99 | + message = "This phone forgets its address and key. To connect again, pair with it again.", | |
| 100 | + confirm = "Remove", | |
| 101 | + danger = true, | |
| 102 | + onDismiss = { removing = false }, | |
| 103 | + onConfirm = { app.removeHost(host) }, | |
| 104 | + ) | |
| 105 | + } | |
| 106 | + } | |
| @@ -0,0 +1,336 @@ | ||
| 1 | + package cc.vectrum.code.ui.settings | |
| 2 | + | |
| 3 | + import androidx.compose.foundation.layout.Arrangement | |
| 4 | + import androidx.compose.foundation.layout.ExperimentalLayoutApi | |
| 5 | + import androidx.compose.foundation.layout.FlowRow | |
| 6 | + import androidx.compose.runtime.Composable | |
| 7 | + import androidx.compose.runtime.LaunchedEffect | |
| 8 | + import androidx.compose.runtime.getValue | |
| 9 | + import androidx.compose.runtime.mutableStateOf | |
| 10 | + import androidx.compose.runtime.saveable.rememberSaveable | |
| 11 | + import androidx.compose.runtime.setValue | |
| 12 | + import androidx.compose.ui.unit.dp | |
| 13 | + import cc.vectrum.code.data.AppState | |
| 14 | + import cc.vectrum.code.data.DevicePrefs | |
| 15 | + import cc.vectrum.code.ui.Lucide | |
| 16 | + import kotlinx.serialization.json.JsonNull | |
| 17 | + import kotlinx.serialization.json.JsonPrimitive | |
| 18 | + | |
| 19 | + // The General page, row for row after the desktop's (src/ui/settings/ | |
| 20 | + // general.rs): New threads, Organization, Behavior, Projects & threads, | |
| 21 | + // Confirmations, Text generation and the folded Legacy features. The rows a | |
| 22 | + // phone has no use for (window, keyboard and notification settings) are left | |
| 23 | + // out; About has a page of its own. | |
| 24 | + | |
| 25 | + private val Workspaces = listOf("local" to "Current checkout", "worktree" to "New worktree") | |
| 26 | + private val Submodules = listOf("recursive" to "Recursive", "top_level" to "Top level only", "skip" to "Skip") | |
| 27 | + private val TimeFormats = listOf("system" to "System default", "hour12" to "12-hour", "hour24" to "24-hour") | |
| 28 | + private val Streaming = listOf( | |
| 29 | + "turn" to "Wait for the full response", | |
| 30 | + "paragraphs" to "Show finished paragraphs", | |
| 31 | + "tokens" to "Stream every token", | |
| 32 | + ) | |
| 33 | + private val DiffStates = listOf(false to "Expanded", true to "Collapsed") | |
| 34 | + private val DiffLayouts = listOf(false to "Stacked", true to "Split") | |
| 35 | + private val FollowUps = listOf("queue" to "Queue", "steer" to "Steer") | |
| 36 | + | |
| 37 | + /** The desktop's permission modes, as the CLI spells them, with T3 Code's descriptions. */ | |
| 38 | + private val Permissions: List<RichOption<String?>> = listOf( | |
| 39 | + RichOption(null, null, "Automatic", "Start with the permissions of the thread you have open."), | |
| 40 | + RichOption("default", Lucide.Lock, "Supervised", "Ask before commands and file changes."), | |
| 41 | + RichOption("acceptEdits", Lucide.PenLine, "Auto-accept edits", "Auto-approve edits, ask before other actions."), | |
| 42 | + RichOption("auto", Lucide.Sparkles, "Auto", "Supported providers approve routine actions; others still ask."), | |
| 43 | + RichOption("plan", Lucide.PencilRuler, "Plan", "Plan first, without editing files or running commands."), | |
| 44 | + RichOption("bypassPermissions", Lucide.LockOpen, "Full access", "Allow commands and edits without prompts."), | |
| 45 | + ) | |
| 46 | + | |
| 47 | + /** This phone's preferences "Restore device defaults" resets, by their labels, as the desktop lists them. */ | |
| 48 | + val DeviceDefaults: List<Pair<String, List<String>>> = listOf( | |
| 49 | + "Theme" to listOf("theme_mode", "light_theme", "dark_theme"), | |
| 50 | + "Diff colors" to listOf("diff_colors"), | |
| 51 | + "Time format" to listOf("time_format"), | |
| 52 | + "Working section" to listOf("working_section"), | |
| 53 | + "Composer context" to listOf("composer_context"), | |
| 54 | + "Default diff file state" to listOf("diff_collapsed"), | |
| 55 | + "Diff whitespace changes" to listOf("hide_whitespace"), | |
| 56 | + "Diff layout" to listOf("diff_side_by_side"), | |
| 57 | + "Show skills in slash menu" to listOf("slash_skills"), | |
| 58 | + "Inline diagrams and UI" to listOf("inline_ui"), | |
| 59 | + "Follow-up behavior" to listOf("follow_up"), | |
| 60 | + "Context window indicator" to listOf("context_meter"), | |
| 61 | + "Plan mode" to listOf("plan_mode"), | |
| 62 | + "Response streaming" to listOf("streaming"), | |
| 63 | + "Fold finished turns" to listOf("fold_turns"), | |
| 64 | + "Show costs" to listOf("show_costs"), | |
| 65 | + "Unpin confirmation" to listOf("confirm_unpin"), | |
| 66 | + "Archive confirmation" to listOf("confirm_settle"), | |
| 67 | + "Delete confirmation" to listOf("confirm_delete"), | |
| 68 | + ) | |
| 69 | + | |
| 70 | + /** The labels of this phone's preferences that differ from the defaults. */ | |
| 71 | + fun differingDefaults(prefs: DevicePrefs): List<String> = | |
| 72 | + DeviceDefaults.filter { (_, keys) -> keys.any { prefs.changed(it) } }.map { it.first } | |
| 73 | + | |
| 74 | + fun restoreDefaults(prefs: DevicePrefs) = DeviceDefaults.flatMap { it.second }.forEach { prefs.reset(it) } | |
| 75 | + | |
| 76 | + @OptIn(ExperimentalLayoutApi::class) | |
| 77 | + @Composable | |
| 78 | + fun GeneralPage(app: AppState, shown: Shown) { | |
| 79 | + val prefs = app.prefs | |
| 80 | + val instances = shown.host?.instances ?: emptyList() | |
| 81 | + | |
| 82 | + Section("New threads", id = "project-defaults") { | |
| 83 | + row { | |
| 84 | + val model = shown.string("default_model") | |
| 85 | + SettingRow( | |
| 86 | + "Model", | |
| 87 | + id = "default-model", | |
| 88 | + description = "Default model for new threads. Projects can override it.", | |
| 89 | + status = if (model == null) "Automatic" else null, | |
| 90 | + scope = shown.row("default_model", "default_reasoning"), | |
| 91 | + onReset = shown.reset("default_model", "default_reasoning"), | |
| 92 | + ) { | |
| 93 | + FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { | |
| 94 | + ModelControl(model, "Automatic", oneOff = false, instances = instances) { | |
| 95 | + shown.set("default_model", it?.let(::JsonPrimitive) ?: JsonNull) | |
| 96 | + } | |
| 97 | + ReasoningControl(shown.string("default_reasoning"), "Automatic") { | |
| 98 | + shown.set("default_reasoning", it?.let(::JsonPrimitive) ?: JsonNull) | |
| 99 | + } | |
| 100 | + } | |
| 101 | + } | |
| 102 | + } | |
| 103 | + row { | |
| 104 | + SettingRow( | |
| 105 | + "Permissions", | |
| 106 | + id = "default-permissions", | |
| 107 | + description = "Default permissions for new threads. Pi and jcode threads always run with full access.", | |
| 108 | + scope = shown.row("default_access"), | |
| 109 | + onReset = shown.reset("default_access"), | |
| 110 | + ) { | |
| 111 | + RichChoice(shown.string("default_access"), Permissions) { | |
| 112 | + shown.set("default_access", it?.let(::JsonPrimitive) ?: JsonNull) | |
| 113 | + } | |
| 114 | + } | |
| 115 | + } | |
| 116 | + row { | |
| 117 | + EnvChoice( | |
| 118 | + shown, "workspace", "Workspace", | |
| 119 | + "Where new threads start. The start screen can switch it for each thread.", | |
| 120 | + Workspaces, id = "new-threads", | |
| 121 | + ) | |
| 122 | + } | |
| 123 | + row { | |
| 124 | + EnvChoice(shown, "submodules", "Submodules", "How new worktrees populate git submodules.", Submodules, id = "worktree-submodules") | |
| 125 | + } | |
| 126 | + } | |
| 127 | + | |
| 128 | + Section("Organization") { | |
| 129 | + row { | |
| 130 | + EnvSwitch( | |
| 131 | + shown, "auto_resume_limited", "Auto-resume limited threads", | |
| 132 | + "Resume usage-limit stops at the reported reset time. Each thread can cancel its scheduled continuation.", | |
| 133 | + id = "auto-resume-limited-threads", | |
| 134 | + ) | |
| 135 | + } | |
| 136 | + row { | |
| 137 | + EnvSwitch( | |
| 138 | + shown, "snooze_limited", "Snooze limited threads", | |
| 139 | + "Snooze usage-limit stops until the reported reset time. Combine with auto-resume to continue when they wake.", | |
| 140 | + id = "snooze-limited-threads", | |
| 141 | + ) | |
| 142 | + } | |
| 143 | + row { | |
| 144 | + PrefSwitch( | |
| 145 | + prefs, "working_section", "Working section (beta)", | |
| 146 | + "Fold working and monitoring threads into a Working section. They return to the top of the inbox when they need you.", | |
| 147 | + id = "working-shelf", | |
| 148 | + ) | |
| 149 | + } | |
| 150 | + row { | |
| 151 | + EnvSwitch( | |
| 152 | + shown, "auto_settle_merged", "Auto-settle merged threads", | |
| 153 | + "Settle a thread when its pull request merges. Closed pull requests still settle automatically.", | |
| 154 | + id = "auto-settle-merged-threads", | |
| 155 | + ) | |
| 156 | + } | |
| 157 | + row { | |
| 158 | + SettingRow( | |
| 159 | + "Auto-settle inactive threads", | |
| 160 | + id = "auto-settle-inactive-threads", | |
| 161 | + description = "Sidebar threads with no activity for this long settle automatically.", | |
| 162 | + inline = true, | |
| 163 | + scope = shown.row("auto_settle_inactive"), | |
| 164 | + onReset = shown.reset("auto_settle_inactive", "inactive_days"), | |
| 165 | + ) { | |
| 166 | + Switch(shown.bool("auto_settle_inactive")) { on -> | |
| 167 | + if (on) { | |
| 168 | + shown.set(mapOf("auto_settle_inactive" to JsonPrimitive(true), "inactive_days" to JsonPrimitive(3))) | |
| 169 | + } else { | |
| 170 | + shown.set("auto_settle_inactive", JsonPrimitive(false)) | |
| 171 | + } | |
| 172 | + } | |
| 173 | + } | |
| 174 | + } | |
| 175 | + if (shown.bool("auto_settle_inactive")) { | |
| 176 | + row { | |
| 177 | + SettingRow( | |
| 178 | + "Days of inactivity before auto-settle", | |
| 179 | + id = "days-before-auto-settle", | |
| 180 | + description = "Any new activity un-settles a thread automatically.", | |
| 181 | + scope = shown.row("inactive_days"), | |
| 182 | + ) { | |
| 183 | + Stepper(shown.int("inactive_days") ?: 3, 1, 1, 90) { shown.set("inactive_days", JsonPrimitive(it)) } | |
| 184 | + } | |
| 185 | + } | |
| 186 | + } | |
| 187 | + } | |
| 188 | + | |
| 189 | + Section("Behavior") { | |
| 190 | + row { | |
| 191 | + PrefChoice(prefs, "time_format", "Time format", "System default follows your phone's clock preference.", TimeFormats, id = "time-format") | |
| 192 | + } | |
| 193 | + row { | |
| 194 | + val description = when (prefs.string("streaming")) { | |
| 195 | + "turn" -> "Text appears once the agent finishes its turn." | |
| 196 | + "tokens" -> "Text appears token by token as the agent writes it." | |
| 197 | + else -> "Each paragraph or code block appears as soon as it is complete." | |
| 198 | + } | |
| 199 | + PrefChoice(prefs, "streaming", "Response streaming", description, Streaming, id = "response-streaming") | |
| 200 | + } | |
| 201 | + row { | |
| 202 | + PrefSwitch( | |
| 203 | + prefs, "fold_turns", "Fold finished turns", | |
| 204 | + "Tuck a finished turn's tool calls and interim messages into one “Worked for” row.", | |
| 205 | + id = "fold-turns", | |
| 206 | + ) | |
| 207 | + } | |
| 208 | + row { | |
| 209 | + PrefSwitch(prefs, "show_costs", "Show costs", "What each turn and thread cost, in the transcript and the project panel.", id = "show-costs") | |
| 210 | + } | |
| 211 | + row { | |
| 212 | + PrefSwitch( | |
| 213 | + prefs, "hide_whitespace", "Hide whitespace changes", | |
| 214 | + "Set whether the diff panel ignores whitespace-only edits by default.", | |
| 215 | + id = "hide-whitespace-changes", | |
| 216 | + ) | |
| 217 | + } | |
| 218 | + row { | |
| 219 | + PrefBoolChoice( | |
| 220 | + prefs, "diff_collapsed", "Default diff file state", | |
| 221 | + "Start with files expanded or collapsed when opening diffs.", DiffStates, id = "default-diff-file-state", | |
| 222 | + ) | |
| 223 | + } | |
| 224 | + row { | |
| 225 | + PrefBoolChoice( | |
| 226 | + prefs, "diff_side_by_side", "Diff layout", | |
| 227 | + "Show diffs stacked or side by side. The toggle in the diff toolbar changes this too.", DiffLayouts, id = "diff-layout", | |
| 228 | + ) | |
| 229 | + } | |
| 230 | + row { | |
| 231 | + PrefSwitch( | |
| 232 | + prefs, "slash_skills", "Show skills in slash menu", | |
| 233 | + "Also include skills in the / command menu. Skills always appear when you type $.", | |
| 234 | + id = "skills-in-slash-menu", | |
| 235 | + ) | |
| 236 | + } | |
| 237 | + row { | |
| 238 | + PrefSwitch( | |
| 239 | + prefs, "inline_ui", "Inline diagrams and UI", | |
| 240 | + "Draw the diagrams, charts and small interactive UIs agents write in their answers.", | |
| 241 | + id = "inline-ui", | |
| 242 | + ) | |
| 243 | + } | |
| 244 | + row { | |
| 245 | + PrefChoice( | |
| 246 | + prefs, "follow_up", "Follow-up behavior", | |
| 247 | + "Queue follow-ups while the agent runs or steer the current run.", FollowUps, id = "follow-up-behavior", | |
| 248 | + ) | |
| 249 | + } | |
| 250 | + row { | |
| 251 | + EnvSwitch( | |
| 252 | + shown, "update_checks", "Provider update checks", | |
| 253 | + "Check installed provider CLIs for newer available versions.", | |
| 254 | + id = "provider-update-checks", | |
| 255 | + ) | |
| 256 | + } | |
| 257 | + row { | |
| 258 | + EnvSwitch( | |
| 259 | + shown, "continue_after_restart", "Continue threads after restarts", | |
| 260 | + "Automatically resume interrupted threads after an update, crash, or machine restart.", | |
| 261 | + id = "continue-threads-after-server-update", | |
| 262 | + ) | |
| 263 | + } | |
| 264 | + } | |
| 265 | + | |
| 266 | + Section("Projects & threads") { | |
| 267 | + row { | |
| 268 | + EnvSwitch( | |
| 269 | + shown, "start_from_origin", "Start from origin", | |
| 270 | + "Creates the worktree from the latest matching branch on origin instead of your local branch.", | |
| 271 | + id = "start-from-origin", | |
| 272 | + ) | |
| 273 | + } | |
| 274 | + } | |
| 275 | + | |
| 276 | + Section("Confirmations") { | |
| 277 | + row { | |
| 278 | + PrefSwitch(prefs, "confirm_unpin", "Unpin confirmation", "Ask before unpinning a thread from the pinned section.", id = "unpin-confirmation") | |
| 279 | + } | |
| 280 | + row { | |
| 281 | + PrefSwitch( | |
| 282 | + prefs, "confirm_settle", "Archive confirmation", | |
| 283 | + "Require a second tap on the inline archive action before a thread is archived.", | |
| 284 | + id = "archive-confirmation", | |
| 285 | + ) | |
| 286 | + } | |
| 287 | + row { | |
| 288 | + PrefSwitch(prefs, "confirm_delete", "Delete confirmation", "Ask before deleting a thread and its chat history.", id = "delete-confirmation") | |
| 289 | + } | |
| 290 | + } | |
| 291 | + | |
| 292 | + Section("Text generation") { | |
| 293 | + row { | |
| 294 | + val model = shown.string("text_model")?.takeIf { it.isNotEmpty() } | |
| 295 | + SettingRow( | |
| 296 | + "Text generation model", | |
| 297 | + id = "text-generation-model", | |
| 298 | + description = "Used for thread titles and worktree branch names. Off keeps the first line of the first message.", | |
| 299 | + scope = shown.row("text_model", "text_reasoning"), | |
| 300 | + onReset = shown.reset("text_model", "text_reasoning"), | |
| 301 | + ) { | |
| 302 | + FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { | |
| 303 | + ModelControl(model, "Off", oneOff = true, instances = instances) { | |
| 304 | + shown.set("text_model", JsonPrimitive(it ?: "")) | |
| 305 | + } | |
| 306 | + if (model != null) { | |
| 307 | + ReasoningControl(shown.string("text_reasoning"), "Default") { | |
| 308 | + shown.set("text_reasoning", it?.let(::JsonPrimitive) ?: JsonNull) | |
| 309 | + } | |
| 310 | + } | |
| 311 | + } | |
| 312 | + } | |
| 313 | + } | |
| 314 | + } | |
| 315 | + | |
| 316 | + var legacy by rememberSaveable { mutableStateOf(false) } | |
| 317 | + // A search jump into the fold opens it. | |
| 318 | + val target = LocalTarget.current | |
| 319 | + LaunchedEffect(target) { if (target?.id?.startsWith("legacy-") == true) legacy = true } | |
| 320 | + FoldedSection("Legacy features", open = legacy, onToggle = { legacy = !legacy }) { | |
| 321 | + row { | |
| 322 | + PrefSwitch( | |
| 323 | + prefs, "plan_mode", "Plan mode (legacy)", | |
| 324 | + "Restore Build/Plan and /plan in the composer. Off uses build mode.", | |
| 325 | + id = "legacy-plan-mode", | |
| 326 | + ) | |
| 327 | + } | |
| 328 | + row { | |
| 329 | + PrefSwitch( | |
| 330 | + prefs, "context_meter", "Context window indicator (legacy)", | |
| 331 | + "Shows context window usage as a circular indicator in the composer.", | |
| 332 | + id = "legacy-context-window-indicator", | |
| 333 | + ) | |
| 334 | + } | |
| 335 | + } | |
| 336 | + } | |
| @@ -0,0 +1,212 @@ | ||
| 1 | + package cc.vectrum.code.ui.settings | |
| 2 | + | |
| 3 | + import androidx.compose.foundation.background | |
| 4 | + import androidx.compose.foundation.clickable | |
| 5 | + import androidx.compose.foundation.layout.Arrangement | |
| 6 | + import androidx.compose.foundation.layout.Box | |
| 7 | + import androidx.compose.foundation.layout.Column | |
| 8 | + import androidx.compose.foundation.layout.Row | |
| 9 | + import androidx.compose.foundation.layout.defaultMinSize | |
| 10 | + import androidx.compose.foundation.layout.fillMaxWidth | |
| 11 | + import androidx.compose.foundation.layout.height | |
| 12 | + import androidx.compose.foundation.layout.offset | |
| 13 | + import androidx.compose.foundation.layout.padding | |
| 14 | + import androidx.compose.foundation.layout.size | |
| 15 | + import androidx.compose.foundation.layout.widthIn | |
| 16 | + import androidx.compose.foundation.shape.CircleShape | |
| 17 | + import androidx.compose.runtime.Composable | |
| 18 | + import androidx.compose.runtime.getValue | |
| 19 | + import androidx.compose.runtime.mutableStateOf | |
| 20 | + import androidx.compose.runtime.saveable.rememberSaveable | |
| 21 | + import androidx.compose.runtime.setValue | |
| 22 | + import androidx.compose.ui.Alignment | |
| 23 | + import androidx.compose.ui.Modifier | |
| 24 | + import androidx.compose.ui.draw.alpha | |
| 25 | + import androidx.compose.ui.draw.dropShadow | |
| 26 | + import androidx.compose.ui.draw.rotate | |
| 27 | + import androidx.compose.ui.graphics.shadow.Shadow | |
| 28 | + import androidx.compose.ui.text.font.FontWeight | |
| 29 | + import androidx.compose.ui.unit.dp | |
| 30 | + import androidx.compose.ui.unit.sp | |
| 31 | + import cc.vectrum.code.data.AppState | |
| 32 | + import cc.vectrum.code.data.HostState | |
| 33 | + import cc.vectrum.code.net.RemoteInstance | |
| 34 | + import cc.vectrum.code.net.RemoteModel | |
| 35 | + import cc.vectrum.code.theme.Cap | |
| 36 | + import cc.vectrum.code.theme.CapKind | |
| 37 | + import cc.vectrum.code.theme.CapSize | |
| 38 | + import cc.vectrum.code.theme.Led | |
| 39 | + import cc.vectrum.code.theme.StatusLed | |
| 40 | + import cc.vectrum.code.theme.Tag | |
| 41 | + import cc.vectrum.code.theme.Txt | |
| 42 | + import cc.vectrum.code.theme.parseHex | |
| 43 | + import cc.vectrum.code.theme.palette | |
| 44 | + import cc.vectrum.code.ui.Lucide | |
| 45 | + import cc.vectrum.code.ui.LucideIcon | |
| 46 | + | |
| 47 | + // The Providers page (src/ui/settings/providers.rs), read-only: each host's | |
| 48 | + // agent instances with their status, account, plan and models. Editing | |
| 49 | + // them isn't on the remote protocol, so it's done on the host. | |
| 50 | + | |
| 51 | + /** The desktop's status headline for an instance, and its light. */ | |
| 52 | + private fun headline(instance: RemoteInstance): Pair<Led, String> = when (instance.status) { | |
| 53 | + "disabled" -> Led.Off to "Disabled" | |
| 54 | + "checking" -> Led.Waiting to "Checking provider status" | |
| 55 | + "missing" -> Led.Failed to "Not found · CLI not detected on PATH." | |
| 56 | + "failed" -> Led.Failed to "Unavailable" | |
| 57 | + else -> { | |
| 58 | + val first = when { | |
| 59 | + instance.provider == "claude" && instance.account != null -> "Authenticated" | |
| 60 | + instance.provider == "claude" -> "Not authenticated" | |
| 61 | + instance.models.isEmpty() -> "Needs attention · No models configured" | |
| 62 | + else -> "Available" | |
| 63 | + } | |
| 64 | + val led = if (first.startsWith("Not") || first.startsWith("Needs")) Led.Waiting else Led.Live | |
| 65 | + led to listOfNotNull(first, instance.plan).joinToString(" · ") | |
| 66 | + } | |
| 67 | + } | |
| 68 | + | |
| 69 | + @Composable | |
| 70 | + fun ProvidersPage(app: AppState) { | |
| 71 | + val hosts = app.hosts.toList() | |
| 72 | + if (hosts.isEmpty()) { | |
| 73 | + Section("Providers", id = "providers") { row { EmptyRow("Pair with a desktop under Connections to see its providers.") } } | |
| 74 | + return | |
| 75 | + } | |
| 76 | + for ((ix, host) in hosts.withIndex()) { | |
| 77 | + HostProviders(host, single = hosts.size == 1, id = if (ix == 0) "providers" else null) | |
| 78 | + } | |
| 79 | + Note("Providers are set up on each environment. Add, sign in to or change them in Vectrum Code there.") | |
| 80 | + } | |
| 81 | + | |
| 82 | + @Composable | |
| 83 | + private fun HostProviders(host: HostState, single: Boolean, id: String?) { | |
| 84 | + val p = palette | |
| 85 | + Section( | |
| 86 | + if (single) "Providers" else host.name, | |
| 87 | + id = id, | |
| 88 | + icon = if (single) null else ({ LucideIcon(Lucide.Server, size = 14.dp, tint = p.ink2) }), | |
| 89 | + headerAction = { | |
| 90 | + Cap( | |
| 91 | + label = "Check now", | |
| 92 | + icon = Lucide.RefreshCw, | |
| 93 | + kind = CapKind.Ghost, | |
| 94 | + size = CapSize.Compact, | |
| 95 | + enabled = host.connected, | |
| 96 | + ) { host.refreshCatalog() } | |
| 97 | + }, | |
| 98 | + ) { | |
| 99 | + val instances = host.instances | |
| 100 | + if (instances.isEmpty()) { | |
| 101 | + row { EmptyRow(if (host.connected) "No providers." else "${host.name} isn't connected.") } | |
| 102 | + } | |
| 103 | + for (instance in instances) row(instance.id) { InstanceRow(instance) } | |
| 104 | + } | |
| 105 | + } | |
| 106 | + | |
| 107 | + @Composable | |
| 108 | + private fun InstanceRow(instance: RemoteInstance) { | |
| 109 | + val p = palette | |
| 110 | + var open by rememberSaveable(instance.id) { mutableStateOf(false) } | |
| 111 | + val (led, text) = headline(instance) | |
| 112 | + Column(Modifier.alpha(if (instance.status == "disabled") 0.6f else 1f)) { | |
| 113 | + Row( | |
| 114 | + Modifier | |
| 115 | + .fillMaxWidth() | |
| 116 | + .defaultMinSize(minHeight = 72.dp) | |
| 117 | + .clickable(interactionSource = null, indication = null) { open = !open } | |
| 118 | + .padding(horizontal = 16.dp, vertical = 12.dp), | |
| 119 | + horizontalArrangement = Arrangement.spacedBy(12.dp), | |
| 120 | + verticalAlignment = Alignment.CenterVertically, | |
| 121 | + ) { | |
| 122 | + InstanceIcon(instance) | |
| 123 | + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { | |
| 124 | + Txt(instance.name.ifBlank { instance.id }, color = p.ink, size = 14.sp, weight = FontWeight.Medium, maxLines = 1) | |
| 125 | + Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) { | |
| 126 | + StatusLed(led, pulse = instance.status == "checking") | |
| 127 | + Txt(text, color = p.ink3, size = 12.sp, maxLines = 2) | |
| 128 | + } | |
| 129 | + } | |
| 130 | + LucideIcon(Lucide.ChevronRight, Modifier.rotate(if (open) 90f else 0f), size = 16.dp, tint = p.ink3) | |
| 131 | + } | |
| 132 | + if (open) InstanceDetail(instance) | |
| 133 | + } | |
| 134 | + } | |
| 135 | + | |
| 136 | + /** An instance's icon, with a dot of its accent color. */ | |
| 137 | + @Composable | |
| 138 | + private fun InstanceIcon(instance: RemoteInstance) { | |
| 139 | + val p = palette | |
| 140 | + val accent = instance.accent?.trim()?.removePrefix("#") | |
| 141 | + ?.takeIf { (it.length == 3 || it.length == 6) && it.all { c -> c.isDigit() || c.lowercaseChar() in 'a'..'f' } } | |
| 142 | + ?.let { parseHex("#$it") } | |
| 143 | + Box { | |
| 144 | + ProviderIcon(instance.provider, size = 20.dp) | |
| 145 | + if (accent != null) { | |
| 146 | + Box( | |
| 147 | + Modifier | |
| 148 | + .align(Alignment.BottomEnd) | |
| 149 | + .offset(3.dp, 3.dp) | |
| 150 | + .size(8.dp) | |
| 151 | + .dropShadow(CircleShape, Shadow(radius = 0.dp, color = p.sHi, spread = 1.5.dp)) | |
| 152 | + .background(accent, CircleShape), | |
| 153 | + ) | |
| 154 | + } | |
| 155 | + } | |
| 156 | + } | |
| 157 | + | |
| 158 | + @Composable | |
| 159 | + private fun InstanceDetail(instance: RemoteInstance) { | |
| 160 | + val p = palette | |
| 161 | + Column(Modifier.padding(start = 16.dp, end = 16.dp, bottom = 12.dp), verticalArrangement = Arrangement.spacedBy(2.dp)) { | |
| 162 | + val account = when { | |
| 163 | + instance.status == "disabled" -> "Turn the provider on to check its account." | |
| 164 | + instance.provider == "claude" && instance.account != null -> "Signed in as ${instance.account}" | |
| 165 | + instance.provider == "claude" -> "Not signed in. Run claude in a terminal and log in, then refresh." | |
| 166 | + instance.provider == "pi" -> "Pi signs in to model providers itself: run pi in a terminal and use /login." | |
| 167 | + else -> null | |
| 168 | + } | |
| 169 | + if (account != null) Txt(account, Modifier.padding(bottom = 6.dp), color = p.ink3, size = 12.sp, lineHeight = 17.sp) | |
| 170 | + val shown = instance.models.filter { !it.hidden } | |
| 171 | + val hidden = instance.models.filter { it.hidden } | |
| 172 | + if (instance.models.isEmpty()) { | |
| 173 | + Txt( | |
| 174 | + when (instance.status) { | |
| 175 | + "checking" -> "Asking for its models…" | |
| 176 | + "disabled" -> "Turn the provider on to list its models." | |
| 177 | + else -> "No models reported." | |
| 178 | + }, | |
| 179 | + color = p.ink3, | |
| 180 | + size = 12.5.sp, | |
| 181 | + ) | |
| 182 | + } | |
| 183 | + ModelGroup("Models", shown, instance.defaultModel) | |
| 184 | + ModelGroup("Hidden from picker", hidden, instance.defaultModel) | |
| 185 | + } | |
| 186 | + } | |
| 187 | + | |
| 188 | + @Composable | |
| 189 | + private fun ModelGroup(title: String, models: List<RemoteModel>, default: String?) { | |
| 190 | + if (models.isEmpty()) return | |
| 191 | + val p = palette | |
| 192 | + Txt(title, Modifier.padding(top = 8.dp, bottom = 4.dp), color = p.ink3, size = 11.5.sp, weight = FontWeight.Medium) | |
| 193 | + for (model in models) { | |
| 194 | + val capabilities = listOfNotNull( | |
| 195 | + "Fast mode".takeIf { model.fastMode }, | |
| 196 | + "Thinking".takeIf { model.efforts.isNotEmpty() }, | |
| 197 | + "Legacy".takeIf { model.legacy }, | |
| 198 | + ) | |
| 199 | + Row( | |
| 200 | + Modifier.fillMaxWidth().height(32.dp), | |
| 201 | + horizontalArrangement = Arrangement.spacedBy(8.dp), | |
| 202 | + verticalAlignment = Alignment.CenterVertically, | |
| 203 | + ) { | |
| 204 | + Row(Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) { | |
| 205 | + Txt(model.name, Modifier.weight(1f, fill = false), color = if (model.hidden) p.ink3 else p.ink, size = 13.sp, maxLines = 1) | |
| 206 | + if (model.name != model.id) Code(model.id, Modifier.widthIn(max = 140.dp)) | |
| 207 | + if (model.id == default) Tag("Default") | |
| 208 | + } | |
| 209 | + if (capabilities.isNotEmpty()) Txt(capabilities.joinToString(" · "), color = p.ink3, size = 11.5.sp, maxLines = 1) | |
| 210 | + } | |
| 211 | + } | |
| 212 | + } | |
| @@ -0,0 +1,97 @@ | ||
| 1 | + package cc.vectrum.code.ui.settings | |
| 2 | + | |
| 3 | + import androidx.compose.runtime.Composable | |
| 4 | + import cc.vectrum.code.data.DevicePrefs | |
| 5 | + import kotlinx.serialization.json.JsonPrimitive | |
| 6 | + | |
| 7 | + // Rows for one setting each: a preference of this phone (DevicePrefs, under | |
| 8 | + // the desktop's keys), or an environment setting of the hosts in scope. | |
| 9 | + | |
| 10 | + /** A switch for one of this phone's preferences. */ | |
| 11 | + @Composable | |
| 12 | + fun PrefSwitch(prefs: DevicePrefs, key: String, title: String, description: String, id: String? = null) { | |
| 13 | + SettingRow( | |
| 14 | + title, | |
| 15 | + id = id, | |
| 16 | + description = description, | |
| 17 | + inline = true, | |
| 18 | + onReset = if (prefs.changed(key)) ({ prefs.reset(key) }) else null, | |
| 19 | + ) { Switch(prefs.bool(key)) { prefs.set(key, it) } } | |
| 20 | + } | |
| 21 | + | |
| 22 | + /** A choice of one of this phone's preferences, by the desktop's spellings of its values. */ | |
| 23 | + @Composable | |
| 24 | + fun PrefChoice( | |
| 25 | + prefs: DevicePrefs, | |
| 26 | + key: String, | |
| 27 | + title: String, | |
| 28 | + description: String, | |
| 29 | + options: List<Pair<String, String>>, | |
| 30 | + id: String? = null, | |
| 31 | + ) { | |
| 32 | + SettingRow( | |
| 33 | + title, | |
| 34 | + id = id, | |
| 35 | + description = description, | |
| 36 | + onReset = if (prefs.changed(key)) ({ prefs.reset(key) }) else null, | |
| 37 | + ) { Choice(prefs.string(key), options) { prefs.set(key, it) } } | |
| 38 | + } | |
| 39 | + | |
| 40 | + /** A choice between two labels for one of this phone's on/off preferences. */ | |
| 41 | + @Composable | |
| 42 | + fun PrefBoolChoice( | |
| 43 | + prefs: DevicePrefs, | |
| 44 | + key: String, | |
| 45 | + title: String, | |
| 46 | + description: String, | |
| 47 | + options: List<Pair<Boolean, String>>, | |
| 48 | + id: String? = null, | |
| 49 | + ) { | |
| 50 | + SettingRow( | |
| 51 | + title, | |
| 52 | + id = id, | |
| 53 | + description = description, | |
| 54 | + onReset = if (prefs.changed(key)) ({ prefs.reset(key) }) else null, | |
| 55 | + ) { Choice(prefs.bool(key), options) { prefs.set(key, it) } } | |
| 56 | + } | |
| 57 | + | |
| 58 | + /** A switch for an environment setting. */ | |
| 59 | + @Composable | |
| 60 | + fun EnvSwitch( | |
| 61 | + shown: Shown, | |
| 62 | + key: String, | |
| 63 | + title: String, | |
| 64 | + description: String, | |
| 65 | + id: String? = null, | |
| 66 | + onChange: ((Boolean) -> Unit)? = null, | |
| 67 | + ) { | |
| 68 | + SettingRow( | |
| 69 | + title, | |
| 70 | + id = id, | |
| 71 | + description = description, | |
| 72 | + inline = true, | |
| 73 | + scope = shown.row(key), | |
| 74 | + onReset = shown.reset(key), | |
| 75 | + ) { Switch(shown.bool(key)) { on -> onChange?.invoke(on) ?: shown.set(key, JsonPrimitive(on)) } } | |
| 76 | + } | |
| 77 | + | |
| 78 | + /** A choice of an environment setting, by the desktop's spellings of its values. */ | |
| 79 | + @Composable | |
| 80 | + fun EnvChoice( | |
| 81 | + shown: Shown, | |
| 82 | + key: String, | |
| 83 | + title: String, | |
| 84 | + description: String, | |
| 85 | + options: List<Pair<String, String>>, | |
| 86 | + id: String? = null, | |
| 87 | + status: String? = null, | |
| 88 | + ) { | |
| 89 | + SettingRow( | |
| 90 | + title, | |
| 91 | + id = id, | |
| 92 | + description = description, | |
| 93 | + status = status, | |
| 94 | + scope = shown.row(key), | |
| 95 | + onReset = shown.reset(key), | |
| 96 | + ) { Choice(shown.string(key) ?: "", options) { shown.set(key, JsonPrimitive(it)) } } | |
| 97 | + } | |
| @@ -0,0 +1,318 @@ | ||
| 1 | + package cc.vectrum.code.ui.settings | |
| 2 | + | |
| 3 | + import androidx.compose.foundation.layout.Arrangement | |
| 4 | + import androidx.compose.foundation.layout.ExperimentalLayoutApi | |
| 5 | + import androidx.compose.foundation.layout.FlowRow | |
| 6 | + import androidx.compose.foundation.layout.Row | |
| 7 | + import androidx.compose.foundation.layout.padding | |
| 8 | + import androidx.compose.runtime.Composable | |
| 9 | + import androidx.compose.runtime.getValue | |
| 10 | + import androidx.compose.runtime.mutableStateOf | |
| 11 | + import androidx.compose.runtime.remember | |
| 12 | + import androidx.compose.runtime.setValue | |
| 13 | + import androidx.compose.ui.Alignment | |
| 14 | + import androidx.compose.ui.Modifier | |
| 15 | + import androidx.compose.ui.unit.dp | |
| 16 | + import androidx.compose.ui.unit.sp | |
| 17 | + import cc.vectrum.code.data.AppState | |
| 18 | + import cc.vectrum.code.data.HostState | |
| 19 | + import cc.vectrum.code.net.SettingsPatch | |
| 20 | + import cc.vectrum.code.theme.Badge | |
| 21 | + import cc.vectrum.code.theme.CapKind | |
| 22 | + import cc.vectrum.code.theme.Txt | |
| 23 | + import cc.vectrum.code.theme.palette | |
| 24 | + import cc.vectrum.code.ui.Lucide | |
| 25 | + import cc.vectrum.code.ui.LucideIcon | |
| 26 | + import cc.vectrum.code.ui.MenuItem | |
| 27 | + import cc.vectrum.code.ui.MenuRow | |
| 28 | + import cc.vectrum.code.ui.MenuSeparator | |
| 29 | + import cc.vectrum.code.util.initials | |
| 30 | + import cc.vectrum.code.util.tintFor | |
| 31 | + import kotlinx.serialization.json.JsonElement | |
| 32 | + import kotlinx.serialization.json.JsonNull | |
| 33 | + import kotlinx.serialization.json.JsonObject | |
| 34 | + import kotlinx.serialization.json.JsonPrimitive | |
| 35 | + import kotlinx.serialization.json.booleanOrNull | |
| 36 | + import kotlinx.serialization.json.intOrNull | |
| 37 | + import kotlinx.serialization.json.jsonPrimitive | |
| 38 | + | |
| 39 | + // The desktop's settings scope (src/ui/settings/scope.rs): "Applying | |
| 40 | + // settings for <project> across <environment>" at the top of the pages with | |
| 41 | + // environment settings. Those live on the hosts; the phone edits one host or | |
| 42 | + // all of them at once, or a project's overrides there. | |
| 43 | + | |
| 44 | + /** The settings a host keeps for itself, with the desktop's defaults (src/settings.rs). */ | |
| 45 | + val EnvironmentDefaults: Map<String, JsonElement> = mapOf( | |
| 46 | + "default_model" to JsonNull, | |
| 47 | + "default_reasoning" to JsonNull, | |
| 48 | + "default_access" to JsonNull, | |
| 49 | + "workspace" to JsonPrimitive("local"), | |
| 50 | + "submodules" to JsonPrimitive("recursive"), | |
| 51 | + "start_from_origin" to JsonPrimitive(true), | |
| 52 | + "branch_naming" to JsonPrimitive("static"), | |
| 53 | + "branch_prefix" to JsonPrimitive("vectrum"), | |
| 54 | + "branch_instructions" to JsonPrimitive(""), | |
| 55 | + "writing_style" to JsonPrimitive("repo_conventions"), | |
| 56 | + "writing_instructions" to JsonPrimitive(""), | |
| 57 | + "follow_pr_templates" to JsonPrimitive(true), | |
| 58 | + "worktrees_dir" to JsonPrimitive(""), | |
| 59 | + "cleanup_deleted_worktrees" to JsonPrimitive(false), | |
| 60 | + "cleanup_inactive_worktrees" to JsonNull, | |
| 61 | + "cleanup_merged_worktrees" to JsonPrimitive(false), | |
| 62 | + "cleanup_unchanged_worktrees" to JsonPrimitive(false), | |
| 63 | + "text_model" to JsonPrimitive("claude:haiku"), | |
| 64 | + "text_reasoning" to JsonPrimitive("low"), | |
| 65 | + "auto_resume_limited" to JsonPrimitive(false), | |
| 66 | + "snooze_limited" to JsonPrimitive(false), | |
| 67 | + "auto_settle_merged" to JsonPrimitive(true), | |
| 68 | + "auto_settle_inactive" to JsonPrimitive(false), | |
| 69 | + "inactive_days" to JsonPrimitive(3), | |
| 70 | + "continue_after_restart" to JsonPrimitive(false), | |
| 71 | + "update_checks" to JsonPrimitive(true), | |
| 72 | + "agent_browser" to JsonPrimitive(true), | |
| 73 | + ) | |
| 74 | + | |
| 75 | + /** The environment settings a project can override. */ | |
| 76 | + val ProjectKeys = setOf( | |
| 77 | + "default_model", "default_reasoning", "default_access", "workspace", "submodules", "start_from_origin", | |
| 78 | + "branch_naming", "branch_prefix", "branch_instructions", "writing_style", "writing_instructions", | |
| 79 | + "follow_pr_templates", "cleanup_inactive_worktrees", "cleanup_merged_worktrees", "cleanup_unchanged_worktrees", | |
| 80 | + "text_model", "text_reasoning", "auto_settle_merged", "auto_settle_inactive", "inactive_days", "agent_browser", | |
| 81 | + ) | |
| 82 | + | |
| 83 | + /** Which hosts and project the environment settings edit; kept as pages change. */ | |
| 84 | + class SettingsScope { | |
| 85 | + /** One host by id, or `null` for all of them. */ | |
| 86 | + var host by mutableStateOf<String?>(null) | |
| 87 | + | |
| 88 | + /** A project by name: the same project on every host. */ | |
| 89 | + var project by mutableStateOf<String?>(null) | |
| 90 | + } | |
| 91 | + | |
| 92 | + /** One place a change is written: a host, and the project's path there when a project is picked. */ | |
| 93 | + class WriteTarget(val host: HostState, val project: String?) { | |
| 94 | + /** The host's settings as this target sees them: defaults, the host's values, the project's overrides. */ | |
| 95 | + val values: Map<String, JsonElement> by lazy { | |
| 96 | + val raw = host.settings | |
| 97 | + val overrides = project?.let { (raw["project_overrides"] as? JsonObject)?.get(it) as? JsonObject } | |
| 98 | + EnvironmentDefaults + raw.filterKeys { it in EnvironmentDefaults } + (overrides ?: emptyMap()) | |
| 99 | + } | |
| 100 | + | |
| 101 | + val overrides: Set<String> | |
| 102 | + get() = project?.let { ((host.settings["project_overrides"] as? JsonObject)?.get(it) as? JsonObject)?.keys } ?: emptySet() | |
| 103 | + } | |
| 104 | + | |
| 105 | + /** What the scope shows: the values the controls display, where the hosts disagree, what's locked. */ | |
| 106 | + class Shown( | |
| 107 | + val app: AppState, | |
| 108 | + val targets: List<WriteTarget>, | |
| 109 | + /** Why environment settings can't change right now. */ | |
| 110 | + val locked: String?, | |
| 111 | + val project: String?, | |
| 112 | + ) { | |
| 113 | + private val first: Map<String, JsonElement> = | |
| 114 | + targets.firstOrNull()?.values | |
| 115 | + ?: (EnvironmentDefaults + (app.hosts.firstOrNull { it.settings.isNotEmpty() }?.settings ?: emptyMap()).filterKeys { it in EnvironmentDefaults }) | |
| 116 | + | |
| 117 | + fun value(key: String): JsonElement = first[key] ?: EnvironmentDefaults[key] ?: JsonNull | |
| 118 | + | |
| 119 | + fun string(key: String): String? = value(key).let { if (it is JsonNull) null else it.jsonPrimitive.content } | |
| 120 | + | |
| 121 | + fun bool(key: String): Boolean = (value(key) as? JsonPrimitive)?.booleanOrNull ?: false | |
| 122 | + | |
| 123 | + fun int(key: String): Int? = (value(key) as? JsonPrimitive)?.intOrNull | |
| 124 | + | |
| 125 | + /** Whether any of [keys] differs from the desktop's default. */ | |
| 126 | + fun changed(vararg keys: String): Boolean = keys.any { value(it) != EnvironmentDefaults[it] } | |
| 127 | + | |
| 128 | + /** The instances of the first host shown, for the model pickers. */ | |
| 129 | + val host: HostState? get() = targets.firstOrNull()?.host ?: app.hosts.firstOrNull { it.connected } | |
| 130 | + | |
| 131 | + /** How a row showing [keys] appears. */ | |
| 132 | + fun row(vararg keys: String): Scoped { | |
| 133 | + val overridable = keys.all { it in ProjectKeys } | |
| 134 | + val lock = locked ?: if (project != null && !overridable) "Applies to the whole environment. Pick All projects to change it." else null | |
| 135 | + val mixed = keys.flatMap { key -> | |
| 136 | + val values = targets.map { it.values[key] ?: JsonNull } | |
| 137 | + if (values.distinct().size > 1) targets.zip(values).map { (target, value) -> target.host.name to describe(value) } else emptyList() | |
| 138 | + } | |
| 139 | + return Scoped( | |
| 140 | + locked = lock, | |
| 141 | + mixed = mixed.ifEmpty { null }, | |
| 142 | + project = project != null && overridable, | |
| 143 | + overridden = keys.any { key -> targets.any { key in it.overrides } }, | |
| 144 | + ) | |
| 145 | + } | |
| 146 | + | |
| 147 | + /** Writes [values] to every target: the host's settings, or the project's overrides there. */ | |
| 148 | + fun set(values: Map<String, JsonElement>) { | |
| 149 | + if (locked != null) return | |
| 150 | + for (target in targets) { | |
| 151 | + val patch = when (val path = target.project) { | |
| 152 | + null -> SettingsPatch(values = JsonObject(values)) | |
| 153 | + else -> SettingsPatch(projects = mapOf(path to JsonObject(values.filterKeys { it in ProjectKeys }))) | |
| 154 | + } | |
| 155 | + target.host.updateSettings(patch) | |
| 156 | + } | |
| 157 | + } | |
| 158 | + | |
| 159 | + fun set(key: String, value: JsonElement) = set(mapOf(key to value)) | |
| 160 | + | |
| 161 | + /** Lets the picked project follow its hosts again for [keys]. */ | |
| 162 | + fun inherit(vararg keys: String) { | |
| 163 | + for (target in targets) { | |
| 164 | + val path = target.project ?: continue | |
| 165 | + target.host.updateSettings(SettingsPatch(projects = mapOf(path to JsonObject(keys.associateWith { JsonNull })))) | |
| 166 | + } | |
| 167 | + } | |
| 168 | + | |
| 169 | + /** The reset a row showing [keys] gets: back to the defaults, or to the hosts' value for a project. */ | |
| 170 | + fun reset(vararg keys: String): (() -> Unit)? { | |
| 171 | + val row = row(*keys) | |
| 172 | + return when { | |
| 173 | + row.locked != null -> null | |
| 174 | + row.project -> if (row.overridden) ({ inherit(*keys) }) else null | |
| 175 | + changed(*keys) -> ({ set(keys.associateWith { EnvironmentDefaults[it] ?: JsonNull }) }) | |
| 176 | + else -> null | |
| 177 | + } | |
| 178 | + } | |
| 179 | + } | |
| 180 | + | |
| 181 | + /** A setting's value in a few words, for saying how the hosts differ. */ | |
| 182 | + private fun describe(value: JsonElement): String = when { | |
| 183 | + value is JsonNull -> "Automatic" | |
| 184 | + value is JsonPrimitive && value.booleanOrNull == true -> "On" | |
| 185 | + value is JsonPrimitive && value.booleanOrNull == false -> "Off" | |
| 186 | + value is JsonPrimitive && value.isString && value.content.isEmpty() -> "Empty" | |
| 187 | + value is JsonPrimitive && value.isString -> value.content.replace('_', ' ').replaceFirstChar { it.uppercase() } | |
| 188 | + else -> value.toString() | |
| 189 | + } | |
| 190 | + | |
| 191 | + /** Works out what [scope] shows, afresh on each composition. */ | |
| 192 | + fun shown(app: AppState, scope: SettingsScope): Shown { | |
| 193 | + val project = scope.project | |
| 194 | + val pick = scope.host | |
| 195 | + val selected: List<HostState> | |
| 196 | + var locked: String? = null | |
| 197 | + if (pick == null) { | |
| 198 | + selected = app.hosts.filter { it.connected && it.settings.isNotEmpty() } | |
| 199 | + } else { | |
| 200 | + val host = app.host(pick) | |
| 201 | + selected = when { | |
| 202 | + host == null -> { | |
| 203 | + locked = "That environment is no longer connected." | |
| 204 | + emptyList() | |
| 205 | + } | |
| 206 | + !host.connected -> { | |
| 207 | + locked = "${host.name} is offline; its settings stay as they are." | |
| 208 | + emptyList() | |
| 209 | + } | |
| 210 | + host.settings.isEmpty() -> { | |
| 211 | + locked = "${host.name} doesn't share its settings yet. Update the app there to edit them here." | |
| 212 | + emptyList() | |
| 213 | + } | |
| 214 | + else -> listOf(host) | |
| 215 | + } | |
| 216 | + } | |
| 217 | + val targets = selected.flatMap { host -> | |
| 218 | + if (project == null) listOf(WriteTarget(host, null)) | |
| 219 | + else host.projects.filter { it.name == project }.map { WriteTarget(host, it.path) } | |
| 220 | + } | |
| 221 | + if (targets.isEmpty() && locked == null) { | |
| 222 | + locked = when { | |
| 223 | + app.hosts.isEmpty() -> "Pair with a desktop under Connections to edit its settings here." | |
| 224 | + project != null -> "$project isn't a project on the selected environments." | |
| 225 | + else -> "No connected environment shares its settings." | |
| 226 | + } | |
| 227 | + } | |
| 228 | + return Shown(app, targets, locked, project) | |
| 229 | + } | |
| 230 | + | |
| 231 | + /** "Applying settings for <project> across <environment>". */ | |
| 232 | + @OptIn(ExperimentalLayoutApi::class) | |
| 233 | + @Composable | |
| 234 | + fun ScopeSentence(app: AppState, scope: SettingsScope) { | |
| 235 | + val p = palette | |
| 236 | + val hosts = app.hosts.toList() | |
| 237 | + val projects = hosts.flatMap { host -> host.projects.map { it.name } }.distinct() | |
| 238 | + val single = hosts.size < 2 | |
| 239 | + val picked = scope.host?.let { id -> hosts.find { it.saved.id == id } } | |
| 240 | + val projectLabel = when (val name = scope.project) { | |
| 241 | + null -> "All projects" | |
| 242 | + in projects -> name | |
| 243 | + else -> "Unavailable project" | |
| 244 | + } | |
| 245 | + val environmentLabel = when { | |
| 246 | + scope.host != null -> picked?.name ?: "Unavailable environment" | |
| 247 | + single -> hosts.firstOrNull()?.name ?: "No environment" | |
| 248 | + else -> "All environments" | |
| 249 | + } | |
| 250 | + val connective = if (scope.host != null || single) "on" else "across" | |
| 251 | + var projectOpen by remember { mutableStateOf(false) } | |
| 252 | + var hostOpen by remember { mutableStateOf(false) } | |
| 253 | + FlowRow( | |
| 254 | + Modifier.padding(horizontal = 16.dp), | |
| 255 | + horizontalArrangement = Arrangement.spacedBy(6.dp), | |
| 256 | + verticalArrangement = Arrangement.spacedBy(4.dp), | |
| 257 | + itemVerticalAlignment = Alignment.CenterVertically, | |
| 258 | + ) { | |
| 259 | + Row(horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically) { | |
| 260 | + Txt("Applying settings for", color = p.ink3, size = 14.sp, maxLines = 1) | |
| 261 | + val badge = scope.project?.takeIf { it in projects } | |
| 262 | + Dropdown( | |
| 263 | + projectOpen, | |
| 264 | + { projectOpen = it }, | |
| 265 | + label = projectLabel, | |
| 266 | + kind = CapKind.Ghost, | |
| 267 | + minWidth = 220.dp, | |
| 268 | + leading = if (badge != null) { { Badge(initials(badge), tintFor(badge)) } } else null, | |
| 269 | + ) { | |
| 270 | + MenuRow("All projects", selected = scope.project == null) { | |
| 271 | + projectOpen = false | |
| 272 | + scope.project = null | |
| 273 | + } | |
| 274 | + if (projects.isNotEmpty()) MenuSeparator() | |
| 275 | + for (name in projects) { | |
| 276 | + MenuItem(selected = scope.project == name, onClick = { | |
| 277 | + projectOpen = false | |
| 278 | + scope.project = name | |
| 279 | + }) { | |
| 280 | + Badge(initials(name), tintFor(name)) | |
| 281 | + Txt(name, color = p.ink, size = 13.sp, maxLines = 1) | |
| 282 | + } | |
| 283 | + } | |
| 284 | + } | |
| 285 | + } | |
| 286 | + Row(horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically) { | |
| 287 | + Txt(connective, color = p.ink3, size = 14.sp, maxLines = 1) | |
| 288 | + Dropdown( | |
| 289 | + hostOpen, | |
| 290 | + { hostOpen = it }, | |
| 291 | + label = environmentLabel, | |
| 292 | + icon = if (scope.host != null || single) Lucide.Server else null, | |
| 293 | + iconColor = p.ink2, | |
| 294 | + kind = CapKind.Ghost, | |
| 295 | + minWidth = 240.dp, | |
| 296 | + ) { | |
| 297 | + if (hosts.size > 1) { | |
| 298 | + MenuRow("All environments", icon = Lucide.Layers, selected = scope.host == null) { | |
| 299 | + hostOpen = false | |
| 300 | + scope.host = null | |
| 301 | + } | |
| 302 | + MenuSeparator() | |
| 303 | + } | |
| 304 | + for (host in hosts) { | |
| 305 | + val checked = scope.host == host.saved.id || (hosts.size == 1 && scope.host == null) | |
| 306 | + MenuItem(selected = checked, onClick = { | |
| 307 | + hostOpen = false | |
| 308 | + scope.host = host.saved.id | |
| 309 | + }) { | |
| 310 | + LucideIcon(Lucide.Server, size = 13.dp, tint = p.ink2) | |
| 311 | + Txt(host.name, Modifier.weight(1f), color = p.ink, size = 13.sp, maxLines = 1) | |
| 312 | + if (!host.connected) Txt("Offline", color = p.ink3, size = 11.5.sp) | |
| 313 | + } | |
| 314 | + } | |
| 315 | + } | |
| 316 | + } | |
| 317 | + } | |
| 318 | + } | |
| @@ -0,0 +1,121 @@ | ||
| 1 | + package cc.vectrum.code.ui.settings | |
| 2 | + | |
| 3 | + import cc.vectrum.code.ui.Lucide | |
| 4 | + | |
| 5 | + // The settings search (src/ui/settings/search.rs): every searchable row, its | |
| 6 | + // page and the words people may remember it by, ranked like T3 Code's. | |
| 7 | + | |
| 8 | + /** The settings pages a phone has, in the desktop's order. */ | |
| 9 | + enum class SettingsPage(val key: String, val label: String, val icon: Lucide) { | |
| 10 | + General("general", "General", Lucide.Settings2), | |
| 11 | + Appearance("appearance", "Appearance", Lucide.Palette), | |
| 12 | + Providers("providers", "Providers", Lucide.Bot), | |
| 13 | + SourceControl("source-control", "Source Control", Lucide.GitBranch), | |
| 14 | + Storage("storage", "Storage", Lucide.HardDrive), | |
| 15 | + Connections("connections", "Connections", Lucide.Link2), | |
| 16 | + Archive("archive", "Archive", Lucide.Archive), | |
| 17 | + About("about", "About", Lucide.Info), | |
| 18 | + ; | |
| 19 | + | |
| 20 | + /** Whether the page edits environment settings, and so shows the scope sentence. */ | |
| 21 | + val scoped: Boolean get() = this == General || this == SourceControl || this == Storage | |
| 22 | + | |
| 23 | + companion object { | |
| 24 | + /** The page a `Screen.Settings` names: `general`, `source-control` or `Source Control`. */ | |
| 25 | + fun find(name: String): SettingsPage? { | |
| 26 | + val wanted = name.trim().lowercase().replace('_', '-').replace(' ', '-') | |
| 27 | + return entries.find { it.key == wanted || it.name.lowercase() == wanted.replace("-", "") } | |
| 28 | + } | |
| 29 | + } | |
| 30 | + } | |
| 31 | + | |
| 32 | + /** One search result: a row (or section) the search can jump to. */ | |
| 33 | + data class SearchItem(val id: String, val title: String, val page: SettingsPage, val terms: String) | |
| 34 | + | |
| 35 | + private fun item(id: String, title: String, page: SettingsPage, terms: String) = SearchItem(id, title, page, terms) | |
| 36 | + | |
| 37 | + /** Searchable settings, in result order. */ | |
| 38 | + val SearchItems: List<SearchItem> = listOf( | |
| 39 | + item("storage-worktrees-location", "Worktree location", SettingsPage.Storage, "worktree location folder directory path drive external disk"), | |
| 40 | + item("storage-worktrees-deleted", "Delete worktrees with deleted threads", SettingsPage.Storage, "worktree cleanup remove delete thread automatic"), | |
| 41 | + item("storage-worktrees-inactive", "Delete inactive worktrees", SettingsPage.Storage, "worktree cleanup remove inactive days retention automatic"), | |
| 42 | + item("storage-worktrees-merged", "Delete merged worktrees", SettingsPage.Storage, "worktree cleanup remove merged pull request automatic"), | |
| 43 | + item("storage-worktrees-unchanged", "Delete unchanged worktrees", SettingsPage.Storage, "worktree cleanup remove unchanged commits default branch automatic"), | |
| 44 | + item("default-model", "Default model", SettingsPage.General, "new thread project provider reasoning effort"), | |
| 45 | + item("default-permissions", "Permissions", SettingsPage.General, "new thread default runtime mode supervised approvals auto accept edits full access plan"), | |
| 46 | + item("color-scheme", "Color scheme", SettingsPage.Appearance, "appearance light dark system mode"), | |
| 47 | + item("theme", "Themes", SettingsPage.Appearance, "appearance colors palette bone graphite tokyo night catppuccin github nord gruvbox rose pine solarized dracula one"), | |
| 48 | + item("diff-color-scheme", "Diff colors", SettingsPage.Appearance, "red green blue orange additions deletions changes counts palette colorblind"), | |
| 49 | + item("composer-context", "Composer context", SettingsPage.Appearance, "branch worktree controls strip below composer"), | |
| 50 | + item("snooze-limited-threads", "Snooze limited threads", SettingsPage.General, "usage quota rate limit reset wake recover continue"), | |
| 51 | + item("auto-resume-limited-threads", "Auto-resume limited threads", SettingsPage.General, "usage quota rate limit reset recover continue"), | |
| 52 | + item("working-shelf", "Working section (beta)", SettingsPage.General, "hide fold running monitoring threads inbox sidebar shelf"), | |
| 53 | + item("auto-settle-inactive-threads", "Auto-settle inactive threads", SettingsPage.General, "sidebar inactivity days no activity automatically"), | |
| 54 | + item("auto-settle-merged-threads", "Auto-settle merged threads", SettingsPage.General, "pull request merge closed automatically sidebar"), | |
| 55 | + item("days-before-auto-settle", "Days of inactivity before auto-settle", SettingsPage.General, "thread timeout activity sidebar"), | |
| 56 | + item("time-format", "Time format", SettingsPage.General, "timestamp clock locale system os 12 hour 24 hour"), | |
| 57 | + item("response-streaming", "Response streaming", SettingsPage.General, "output token paragraph buffered wait turn"), | |
| 58 | + item("fold-turns", "Fold finished turns", SettingsPage.General, "worked for tool calls collapse transcript"), | |
| 59 | + item("show-costs", "Show costs", SettingsPage.General, "price dollars spend turn thread"), | |
| 60 | + item("hide-whitespace-changes", "Hide whitespace changes", SettingsPage.General, "diff ignore spaces edits default"), | |
| 61 | + item("default-diff-file-state", "Default diff file state", SettingsPage.General, "collapsed expanded collapse expand files"), | |
| 62 | + item("diff-layout", "Diff layout", SettingsPage.General, "stacked split side by side unified inline view"), | |
| 63 | + item("skills-in-slash-menu", "Show skills in slash menu", SettingsPage.General, "command menu dollar $ slash /"), | |
| 64 | + item("inline-ui", "Inline diagrams and UI", SettingsPage.General, "mermaid openui charts tables forms svg html preview artifacts render answers"), | |
| 65 | + item("follow-up-behavior", "Follow-up behavior", SettingsPage.General, "queue steer running turn send default behavior composer"), | |
| 66 | + item("provider-update-checks", "Provider update checks", SettingsPage.General, "installed cli versions newer available claude pi jcode"), | |
| 67 | + item("continue-threads-after-server-update", "Continue threads after restarts", SettingsPage.General, "resume running active interrupted work restart reboot machine crash update automatically"), | |
| 68 | + item("new-threads", "Workspace", SettingsPage.General, "new threads default workspace mode local worktree checkout"), | |
| 69 | + item("worktree-submodules", "Submodules", SettingsPage.General, "git submodule init recursive top-level none worktree"), | |
| 70 | + item("start-from-origin", "Start from origin", SettingsPage.General, "new worktrees latest matching remote branch local"), | |
| 71 | + item("unpin-confirmation", "Unpin confirmation", SettingsPage.General, "ask before thread pinned section"), | |
| 72 | + item("archive-confirmation", "Archive confirmation", SettingsPage.General, "ask before thread second click inline action settle"), | |
| 73 | + item("delete-confirmation", "Delete confirmation", SettingsPage.General, "ask before thread chat history"), | |
| 74 | + item("text-generation-model", "Text generation model", SettingsPage.General, "generated thread titles default provider haiku"), | |
| 75 | + item("legacy-plan-mode", "Plan mode (legacy)", SettingsPage.General, "build plan composer old shift tab"), | |
| 76 | + item("legacy-context-window-indicator", "Context window indicator (legacy)", SettingsPage.General, "composer meter usage tokens circle old"), | |
| 77 | + item("providers", "Providers", SettingsPage.Providers, "agents cli claude pi jcode sign in install subscription instances authentication models"), | |
| 78 | + item("worktree-branch-naming", "Worktree branch naming", SettingsPage.SourceControl, "static semantic prefix custom prompt instructions feat fix refactor chore"), | |
| 79 | + item("source-control-writing-style", "Source control writing style", SettingsPage.SourceControl, "commit message pull request repository conventions conventional commits custom instructions"), | |
| 80 | + item("follow-change-request-templates", "Follow change request templates", SettingsPage.SourceControl, "pull request template description markdown"), | |
| 81 | + item("branch-prefix", "Branch prefix", SettingsPage.SourceControl, "worktree branch name prefix vectrum"), | |
| 82 | + item("branch-naming-instructions", "Branch naming instructions", SettingsPage.SourceControl, "worktree branch custom prompt model"), | |
| 83 | + item("connections-device", "This device", SettingsPage.Connections, "connections phone client remote access"), | |
| 84 | + item("remote-environments", "Environments", SettingsPage.Connections, "add pair backend host code devices remote connect qr scan"), | |
| 85 | + item("archive", "Archived threads", SettingsPage.Archive, "restore reopen settled deleted history projects unarchive"), | |
| 86 | + item("version", "Version", SettingsPage.About, "about release update vectrum code"), | |
| 87 | + item("open-source-licenses", "Open source licenses", SettingsPage.About, "notices dependencies fonts geist ofl mit apache"), | |
| 88 | + ) | |
| 89 | + | |
| 90 | + /** Lowercases and keeps letters, digits and spaces: T3 Code's `normalizeSearchText`. */ | |
| 91 | + private fun normalize(text: String): String = | |
| 92 | + text.map { if (it.isLetterOrDigit() || it == '$' || it == '/' || it == '+') it.lowercaseChar() else ' ' } | |
| 93 | + .joinToString("") | |
| 94 | + .split(' ') | |
| 95 | + .filter { it.isNotEmpty() } | |
| 96 | + .joinToString(" ") | |
| 97 | + | |
| 98 | + /** | |
| 99 | + * The items matching [query], best first: every word must appear in the | |
| 100 | + * title, the page name or the terms; exact and leading title matches rank | |
| 101 | + * highest. | |
| 102 | + */ | |
| 103 | + fun searchSettings(query: String, items: List<SearchItem> = SearchItems): List<SearchItem> { | |
| 104 | + val wanted = normalize(query) | |
| 105 | + if (wanted.isEmpty()) return emptyList() | |
| 106 | + val tokens = wanted.split(' ') | |
| 107 | + return items.withIndex().mapNotNull { (index, item) -> | |
| 108 | + val title = normalize(item.title) | |
| 109 | + val fields = listOf(title, normalize(item.page.label), normalize(item.terms)) | |
| 110 | + if (!tokens.all { token -> fields.any { it.contains(token) } }) return@mapNotNull null | |
| 111 | + val rank = when { | |
| 112 | + title == wanted -> 5 | |
| 113 | + title.startsWith(wanted) -> 4 | |
| 114 | + title.contains(wanted) -> 3 | |
| 115 | + tokens.all { title.contains(it) } -> 2 | |
| 116 | + fields.any { it.contains(wanted) } -> 1 | |
| 117 | + else -> 0 | |
| 118 | + } | |
| 119 | + Triple(index, rank, item) | |
| 120 | + }.sortedWith(compareByDescending<Triple<Int, Int, SearchItem>> { it.second }.thenBy { it.first }).map { it.third } | |
| 121 | + } | |
| @@ -1,19 +1,365 @@ | ||
| 1 | 1 | package cc.vectrum.code.ui.settings |
| 2 | 2 | |
| 3 | + import androidx.activity.compose.BackHandler | |
| 4 | + import androidx.compose.foundation.Image | |
| 5 | + import androidx.compose.foundation.ScrollState | |
| 6 | + import androidx.compose.foundation.background | |
| 7 | + import androidx.compose.foundation.clickable | |
| 8 | + import androidx.compose.foundation.layout.Arrangement | |
| 3 | 9 | import androidx.compose.foundation.layout.Box |
| 10 | + import androidx.compose.foundation.layout.BoxWithConstraints | |
| 11 | + import androidx.compose.foundation.layout.Column | |
| 12 | + import androidx.compose.foundation.layout.Row | |
| 13 | + import androidx.compose.foundation.layout.Spacer | |
| 14 | + import androidx.compose.foundation.layout.defaultMinSize | |
| 15 | + import androidx.compose.foundation.layout.fillMaxHeight | |
| 16 | + import androidx.compose.foundation.layout.fillMaxSize | |
| 17 | + import androidx.compose.foundation.layout.fillMaxWidth | |
| 18 | + import androidx.compose.foundation.layout.height | |
| 4 | 19 | import androidx.compose.foundation.layout.padding |
| 20 | + import androidx.compose.foundation.layout.size | |
| 21 | + import androidx.compose.foundation.layout.width | |
| 22 | + import androidx.compose.foundation.layout.widthIn | |
| 23 | + import androidx.compose.foundation.rememberScrollState | |
| 24 | + import androidx.compose.foundation.shape.RoundedCornerShape | |
| 25 | + import androidx.compose.foundation.text.BasicTextField | |
| 26 | + import androidx.compose.foundation.text.KeyboardOptions | |
| 27 | + import androidx.compose.foundation.verticalScroll | |
| 5 | 28 | import androidx.compose.runtime.Composable |
| 29 | + import androidx.compose.runtime.CompositionLocalProvider | |
| 30 | + import androidx.compose.runtime.LaunchedEffect | |
| 31 | + import androidx.compose.runtime.getValue | |
| 32 | + import androidx.compose.runtime.mutableIntStateOf | |
| 33 | + import androidx.compose.runtime.mutableStateOf | |
| 34 | + import androidx.compose.runtime.remember | |
| 35 | + import androidx.compose.runtime.setValue | |
| 36 | + import androidx.compose.ui.Alignment | |
| 6 | 37 | import androidx.compose.ui.Modifier |
| 38 | + import androidx.compose.ui.draw.clip | |
| 39 | + import androidx.compose.ui.graphics.ColorFilter | |
| 40 | + import androidx.compose.ui.graphics.SolidColor | |
| 41 | + import androidx.compose.ui.platform.LocalConfiguration | |
| 42 | + import androidx.compose.ui.platform.LocalContext | |
| 43 | + import androidx.compose.ui.res.painterResource | |
| 44 | + import androidx.compose.ui.text.TextStyle | |
| 45 | + import androidx.compose.ui.text.font.FontWeight | |
| 46 | + import androidx.compose.ui.text.input.ImeAction | |
| 47 | + import androidx.compose.ui.text.input.TextFieldValue | |
| 7 | 48 | import androidx.compose.ui.unit.dp |
| 49 | + import androidx.compose.ui.unit.sp | |
| 50 | + import cc.vectrum.code.R | |
| 8 | 51 | import cc.vectrum.code.data.AppState |
| 9 | - import cc.vectrum.code.data.OpenThread | |
| 10 | - import cc.vectrum.code.data.ThreadRef | |
| 52 | + import cc.vectrum.code.theme.Cap | |
| 53 | + import cc.vectrum.code.theme.CapKind | |
| 54 | + import cc.vectrum.code.theme.CapSize | |
| 11 | 55 | import cc.vectrum.code.theme.Engraved |
| 56 | + import cc.vectrum.code.theme.Mono | |
| 57 | + import cc.vectrum.code.theme.Rule | |
| 58 | + import cc.vectrum.code.theme.Sans | |
| 59 | + import cc.vectrum.code.theme.Tag | |
| 60 | + import cc.vectrum.code.theme.Txt | |
| 61 | + import cc.vectrum.code.theme.VRule | |
| 62 | + import cc.vectrum.code.theme.palette | |
| 63 | + import cc.vectrum.code.theme.raised | |
| 64 | + import cc.vectrum.code.theme.well | |
| 65 | + import cc.vectrum.code.ui.ConfirmDialog | |
| 66 | + import cc.vectrum.code.ui.Lucide | |
| 67 | + import cc.vectrum.code.ui.LucideIcon | |
| 12 | 68 | import cc.vectrum.code.ui.Nav |
| 13 | 69 | import cc.vectrum.code.ui.Screen |
| 70 | + import kotlinx.coroutines.delay | |
| 71 | + | |
| 72 | + // The settings view (src/ui/settings/mod.rs), laid out like T3 Code's: a | |
| 73 | + // search field and the pages, then the open page under a "Settings / Page" | |
| 74 | + // breadcrumb. On a phone the page list comes first and each page fills the | |
| 75 | + // sheet; on a tablet they sit side by side, as on the desktop. Every change | |
| 76 | + // is saved at once: this phone's preferences here, environment settings on | |
| 77 | + // the hosts. | |
| 78 | + | |
| 79 | + /** The widest a settings page grows (T3 Code's "readable" width). */ | |
| 80 | + private val ReadableWidth = 896.dp | |
| 81 | + | |
| 82 | + /** Providers and Connections. */ | |
| 83 | + private val WideWidth = 1024.dp | |
| 84 | + | |
| 85 | + /** The page list's width beside a page, on a tablet. */ | |
| 86 | + private val NavWidth = 260.dp | |
| 14 | 87 | |
| 15 | - // Placeholder until it's built. | |
| 16 | 88 | @Composable |
| 17 | 89 | fun SettingsScreen(app: AppState, nav: Nav, page: String) { |
| 18 | - Box(Modifier.padding(16.dp)) { Engraved("Settings") } | |
| 90 | + val scope = remember { SettingsScope() } | |
| 91 | + var query by remember { mutableStateOf(TextFieldValue("")) } | |
| 92 | + var target by remember { mutableStateOf<Target?>(null) } | |
| 93 | + var jumps by remember { mutableIntStateOf(0) } | |
| 94 | + val current = SettingsPage.find(page) | |
| 95 | + | |
| 96 | + // Let the pulse play out, then forget the target so it doesn't pulse again. | |
| 97 | + LaunchedEffect(target) { | |
| 98 | + if (target != null) { | |
| 99 | + delay(1600) | |
| 100 | + target = null | |
| 101 | + } | |
| 102 | + } | |
| 103 | + | |
| 104 | + BoxWithConstraints(Modifier.fillMaxSize()) { | |
| 105 | + val wide = maxWidth >= 720.dp | |
| 106 | + val open = { next: SettingsPage? -> | |
| 107 | + target = null | |
| 108 | + nav.replace(Screen.Settings(next?.key ?: "")) | |
| 109 | + } | |
| 110 | + val jump = { item: SearchItem -> | |
| 111 | + query = TextFieldValue("") | |
| 112 | + jumps += 1 | |
| 113 | + target = Target(item.id, jumps) | |
| 114 | + nav.replace(Screen.Settings(item.page.key)) | |
| 115 | + } | |
| 116 | + // Back clears a search first, then leaves a page for the list. | |
| 117 | + BackHandler(enabled = !nav.sidebarOpen && !nav.palette && (query.text.isNotEmpty() || (!wide && current != null))) { | |
| 118 | + if (query.text.isNotEmpty()) query = TextFieldValue("") else open(null) | |
| 119 | + } | |
| 120 | + val list = @Composable { modifier: Modifier -> | |
| 121 | + PageList( | |
| 122 | + app, nav, if (wide) current ?: SettingsPage.General else null, | |
| 123 | + query, { query = it }, open, jump, modifier, | |
| 124 | + ) | |
| 125 | + } | |
| 126 | + if (wide) { | |
| 127 | + Row(Modifier.fillMaxSize()) { | |
| 128 | + list(Modifier.width(NavWidth).fillMaxHeight()) | |
| 129 | + Spacer(Modifier.width(1.dp).fillMaxHeight().background(palette.rule)) | |
| 130 | + PageView(app, nav, current ?: SettingsPage.General, scope, target, back = null, open = open, modifier = Modifier.weight(1f)) | |
| 131 | + } | |
| 132 | + } else if (current == null) { | |
| 133 | + list(Modifier.fillMaxSize()) | |
| 134 | + } else { | |
| 135 | + PageView(app, nav, current, scope, target, back = { open(null) }, open = open, modifier = Modifier.fillMaxSize()) | |
| 136 | + } | |
| 137 | + } | |
| 138 | + } | |
| 139 | + | |
| 140 | + /** The pages and the search over them, with the way back to the threads. */ | |
| 141 | + @Composable | |
| 142 | + private fun PageList( | |
| 143 | + app: AppState, | |
| 144 | + nav: Nav, | |
| 145 | + selected: SettingsPage?, | |
| 146 | + query: TextFieldValue, | |
| 147 | + onQuery: (TextFieldValue) -> Unit, | |
| 148 | + open: (SettingsPage?) -> Unit, | |
| 149 | + jump: (SearchItem) -> Unit, | |
| 150 | + modifier: Modifier, | |
| 151 | + ) { | |
| 152 | + val p = palette | |
| 153 | + val context = LocalContext.current | |
| 154 | + val drawer = LocalConfiguration.current.screenWidthDp < 840 | |
| 155 | + val searching = query.text.isNotBlank() | |
| 156 | + val results = searchSettings(query.text) | |
| 157 | + Column(modifier) { | |
| 158 | + Row( | |
| 159 | + Modifier.fillMaxWidth().height(52.dp).padding(start = if (drawer) 8.dp else 16.dp, end = 16.dp), | |
| 160 | + horizontalArrangement = Arrangement.spacedBy(8.dp), | |
| 161 | + verticalAlignment = Alignment.CenterVertically, | |
| 162 | + ) { | |
| 163 | + if (drawer) Cap(icon = Lucide.PanelLeftOpen, kind = CapKind.Ghost, size = CapSize.Compact) { nav.sidebarOpen = true } | |
| 164 | + Image( | |
| 165 | + painterResource(R.drawable.vectrum_logo), | |
| 166 | + contentDescription = null, | |
| 167 | + modifier = Modifier.size(18.dp), | |
| 168 | + colorFilter = ColorFilter.tint(p.ink), | |
| 169 | + ) | |
| 170 | + Txt("Vectrum", color = p.ink, size = 15.sp, weight = FontWeight.SemiBold, maxLines = 1) | |
| 171 | + Tag("Settings") | |
| 172 | + } | |
| 173 | + Column( | |
| 174 | + Modifier.weight(1f).verticalScroll(rememberScrollState()).padding(start = 8.dp, end = 8.dp, top = 8.dp, bottom = 8.dp), | |
| 175 | + verticalArrangement = Arrangement.spacedBy(3.dp), | |
| 176 | + ) { | |
| 177 | + Box(Modifier.padding(bottom = 6.dp)) { SearchField(query, onQuery) } | |
| 178 | + when { | |
| 179 | + searching && results.isEmpty() -> Box(Modifier.fillMaxWidth().padding(vertical = 24.dp), contentAlignment = Alignment.Center) { | |
| 180 | + Txt("No settings found", color = p.ink3, size = 12.sp) | |
| 181 | + } | |
| 182 | + searching -> results.forEach { item -> ResultRow(item) { jump(item) } } | |
| 183 | + else -> SettingsPage.entries.forEach { page -> | |
| 184 | + val count = when (page) { | |
| 185 | + SettingsPage.Archive -> settledCount(app).takeIf { it > 0 }?.toString() | |
| 186 | + else -> null | |
| 187 | + } | |
| 188 | + PageRow(page, page == selected, count) { open(page) } | |
| 189 | + } | |
| 190 | + } | |
| 191 | + } | |
| 192 | + Row( | |
| 193 | + Modifier.fillMaxWidth().height(48.dp).padding(horizontal = 12.dp), | |
| 194 | + horizontalArrangement = Arrangement.spacedBy(8.dp), | |
| 195 | + verticalAlignment = Alignment.CenterVertically, | |
| 196 | + ) { | |
| 197 | + Cap(label = "Back", icon = Lucide.ArrowLeft, kind = CapKind.Ghost, size = CapSize.Compact) { | |
| 198 | + if (!nav.back()) nav.home() | |
| 199 | + } | |
| 200 | + Spacer(Modifier.weight(1f)) | |
| 201 | + Engraved("v${appVersion(context)}") | |
| 202 | + } | |
| 203 | + } | |
| 204 | + } | |
| 205 | + | |
| 206 | + @Composable | |
| 207 | + private fun SearchField(query: TextFieldValue, onQuery: (TextFieldValue) -> Unit) { | |
| 208 | + val p = palette | |
| 209 | + Row( | |
| 210 | + Modifier.fillMaxWidth().height(36.dp).well(p, RoundedCornerShape(12.dp)).padding(start = 10.dp, end = 4.dp), | |
| 211 | + horizontalArrangement = Arrangement.spacedBy(8.dp), | |
| 212 | + verticalAlignment = Alignment.CenterVertically, | |
| 213 | + ) { | |
| 214 | + LucideIcon(Lucide.Search, size = 15.dp, tint = p.ink3) | |
| 215 | + Box(Modifier.weight(1f), contentAlignment = Alignment.CenterStart) { | |
| 216 | + if (query.text.isEmpty()) Txt("Search", color = p.fieldInk, size = 13.5.sp, maxLines = 1) | |
| 217 | + BasicTextField( | |
| 218 | + value = query, | |
| 219 | + onValueChange = onQuery, | |
| 220 | + singleLine = true, | |
| 221 | + textStyle = TextStyle(color = p.ink, fontSize = 13.5.sp, fontFamily = Sans), | |
| 222 | + cursorBrush = SolidColor(p.ink), | |
| 223 | + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), | |
| 224 | + modifier = Modifier.fillMaxWidth(), | |
| 225 | + ) | |
| 226 | + } | |
| 227 | + if (query.text.isNotEmpty()) { | |
| 228 | + Cap(icon = Lucide.X, kind = CapKind.Ghost, size = CapSize.Mini) { onQuery(TextFieldValue("")) } | |
| 229 | + } | |
| 230 | + } | |
| 231 | + } | |
| 232 | + | |
| 233 | + @Composable | |
| 234 | + private fun PageRow(page: SettingsPage, selected: Boolean, count: String?, onClick: () -> Unit) { | |
| 235 | + val p = palette | |
| 236 | + val shape = RoundedCornerShape(12.dp) | |
| 237 | + Row( | |
| 238 | + Modifier | |
| 239 | + .fillMaxWidth() | |
| 240 | + .height(40.dp) | |
| 241 | + .then(if (selected) Modifier.raised(p, shape) else Modifier) | |
| 242 | + .clip(shape) | |
| 243 | + .clickable(onClick = onClick) | |
| 244 | + .padding(horizontal = 12.dp), | |
| 245 | + horizontalArrangement = Arrangement.spacedBy(10.dp), | |
| 246 | + verticalAlignment = Alignment.CenterVertically, | |
| 247 | + ) { | |
| 248 | + LucideIcon(page.icon, size = 15.dp, tint = if (selected) p.ink else p.ink2) | |
| 249 | + Txt(page.label, Modifier.weight(1f), color = if (selected) p.ink else p.ink2, size = 13.5.sp, weight = FontWeight.Medium, maxLines = 1) | |
| 250 | + if (count != null) Txt(count, color = p.ink3, size = 10.5.sp, family = Mono) | |
| 251 | + else if (!selected) LucideIcon(Lucide.ChevronRight, size = 14.dp, tint = p.ink3.copy(alpha = 0.6f)) | |
| 252 | + } | |
| 253 | + } | |
| 254 | + | |
| 255 | + @Composable | |
| 256 | + private fun ResultRow(item: SearchItem, onClick: () -> Unit) { | |
| 257 | + val p = palette | |
| 258 | + val shape = RoundedCornerShape(12.dp) | |
| 259 | + Row( | |
| 260 | + Modifier | |
| 261 | + .fillMaxWidth() | |
| 262 | + .defaultMinSize(minHeight = 44.dp) | |
| 263 | + .clip(shape) | |
| 264 | + .clickable(onClick = onClick) | |
| 265 | + .padding(horizontal = 12.dp, vertical = 6.dp), | |
| 266 | + horizontalArrangement = Arrangement.spacedBy(10.dp), | |
| 267 | + ) { | |
| 268 | + LucideIcon(item.page.icon, Modifier.padding(top = 2.dp), size = 14.dp, tint = p.ink3.copy(alpha = 0.8f)) | |
| 269 | + Column { | |
| 270 | + Txt(item.title, color = p.ink, size = 13.5.sp, weight = FontWeight.Medium, maxLines = 1) | |
| 271 | + Txt(item.page.label, color = p.ink3, size = 11.sp, maxLines = 1) | |
| 272 | + } | |
| 273 | + } | |
| 274 | + } | |
| 275 | + | |
| 276 | + /** One page under its "Settings / Page" breadcrumb. [back] leaves it for the page list, on a phone. */ | |
| 277 | + @Composable | |
| 278 | + private fun PageView( | |
| 279 | + app: AppState, | |
| 280 | + nav: Nav, | |
| 281 | + page: SettingsPage, | |
| 282 | + scope: SettingsScope, | |
| 283 | + target: Target?, | |
| 284 | + back: (() -> Unit)?, | |
| 285 | + open: (SettingsPage?) -> Unit, | |
| 286 | + modifier: Modifier, | |
| 287 | + ) { | |
| 288 | + val p = palette | |
| 289 | + val prefs = app.prefs | |
| 290 | + var restoring by remember { mutableStateOf(false) } | |
| 291 | + BoxWithConstraints(modifier) { | |
| 292 | + val narrow = maxWidth < 480.dp | |
| 293 | + Column(Modifier.fillMaxSize()) { | |
| 294 | + Row( | |
| 295 | + Modifier.fillMaxWidth().height(52.dp).padding(start = if (back != null) 8.dp else 20.dp, end = 12.dp), | |
| 296 | + horizontalArrangement = Arrangement.spacedBy(8.dp), | |
| 297 | + verticalAlignment = Alignment.CenterVertically, | |
| 298 | + ) { | |
| 299 | + if (back != null) Cap(icon = Lucide.ArrowLeft, kind = CapKind.Ghost, size = CapSize.Compact, onClick = back) | |
| 300 | + Txt( | |
| 301 | + "Settings", | |
| 302 | + Modifier.clickable(interactionSource = null, indication = null) { | |
| 303 | + if (back != null) back() else open(SettingsPage.General) | |
| 304 | + }, | |
| 305 | + color = p.ink3, | |
| 306 | + size = 14.sp, | |
| 307 | + weight = FontWeight.Medium, | |
| 308 | + maxLines = 1, | |
| 309 | + ) | |
| 310 | + Txt("/", color = p.ink3, size = 14.sp) | |
| 311 | + Txt(page.label, Modifier.weight(1f), color = p.ink, size = 14.sp, weight = FontWeight.SemiBold, maxLines = 1) | |
| 312 | + if (page == SettingsPage.General) { | |
| 313 | + val changed = differingDefaults(prefs) | |
| 314 | + Cap( | |
| 315 | + label = if (narrow) null else "Restore device defaults", | |
| 316 | + icon = Lucide.RotateCcw, | |
| 317 | + kind = CapKind.Ghost, | |
| 318 | + size = CapSize.Compact, | |
| 319 | + enabled = changed.isNotEmpty(), | |
| 320 | + ) { restoring = true } | |
| 321 | + if (restoring) { | |
| 322 | + ConfirmDialog( | |
| 323 | + title = "Restore default settings?", | |
| 324 | + message = "This will reset: ${changed.joinToString(", ")}.", | |
| 325 | + confirm = "Restore defaults", | |
| 326 | + danger = true, | |
| 327 | + onDismiss = { restoring = false }, | |
| 328 | + onConfirm = { restoreDefaults(prefs) }, | |
| 329 | + ) | |
| 330 | + } | |
| 331 | + } | |
| 332 | + } | |
| 333 | + Rule() | |
| 334 | + val scroll = remember(page) { ScrollState(0) } | |
| 335 | + Box( | |
| 336 | + Modifier.weight(1f).fillMaxWidth().verticalScroll(scroll), | |
| 337 | + contentAlignment = Alignment.TopCenter, | |
| 338 | + ) { | |
| 339 | + val width = if (page == SettingsPage.Providers || page == SettingsPage.Connections) WideWidth else ReadableWidth | |
| 340 | + Column( | |
| 341 | + Modifier | |
| 342 | + .widthIn(max = width) | |
| 343 | + .fillMaxWidth() | |
| 344 | + .padding(start = if (narrow) 12.dp else 24.dp, end = if (narrow) 12.dp else 24.dp, top = 24.dp, bottom = 48.dp), | |
| 345 | + verticalArrangement = Arrangement.spacedBy(32.dp), | |
| 346 | + ) { | |
| 347 | + CompositionLocalProvider(LocalTarget provides target) { | |
| 348 | + val shown = shown(app, scope) | |
| 349 | + if (page.scoped) ScopeSentence(app, scope) | |
| 350 | + when (page) { | |
| 351 | + SettingsPage.General -> GeneralPage(app, shown) | |
| 352 | + SettingsPage.Appearance -> AppearancePage(prefs) | |
| 353 | + SettingsPage.Providers -> ProvidersPage(app) | |
| 354 | + SettingsPage.SourceControl -> SourceControlPage(shown) | |
| 355 | + SettingsPage.Storage -> StoragePage(shown) | |
| 356 | + SettingsPage.Connections -> ConnectionsPage(app, nav) | |
| 357 | + SettingsPage.Archive -> ArchivePage(app) | |
| 358 | + SettingsPage.About -> AboutPage(app) | |
| 359 | + } | |
| 360 | + } | |
| 361 | + } | |
| 362 | + } | |
| 363 | + } | |
| 364 | + } | |
| 19 | 365 | } |
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.