Skip to content

Commit 7c63e5d

Browse files
committed
feat(settings): add custom browser page title
Persist an instance-wide page title while preserving project and activity prefixes. Include validation, settings backup support, and frontend/backend regression coverage.
1 parent bebef23 commit 7c63e5d

18 files changed

Lines changed: 664 additions & 7 deletions

api/auth.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -394,7 +394,8 @@ func isAnonymousPath(path string, cfg *utils.AppConfig) bool {
394394
"/api/v1/auth/status",
395395
"/api/v1/auth/login",
396396
"/api/v1/auth/logout",
397-
"/api/v1/auth/password/enable":
397+
"/api/v1/auth/password/enable",
398+
"/api/v1/system/page-title-settings":
398399
return true
399400
default:
400401
return false

api/system.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,10 @@ type dailyTipSettings struct {
6262
Enabled bool `json:"enabled" doc:"是否启用每日小技巧"`
6363
}
6464

65+
type pageTitleSettings struct {
66+
Title string `json:"title" doc:"浏览器标签页使用的应用标题"`
67+
}
68+
6569
func registerSystemRoutes(
6670
group *huma.Group,
6771
cfg *utils.AppConfig,
@@ -273,6 +277,42 @@ func registerSystemRoutes(
273277
op.Tags = []string{systemTag}
274278
})
275279

280+
huma.Get(group, "/system/page-title-settings", func(ctx context.Context, input *struct{}) (*h.ItemResponse[pageTitleSettings], error) {
281+
resp := h.NewItemResponse(pageTitleSettings{
282+
Title: cfg.UI.PageTitle,
283+
})
284+
resp.Status = http.StatusOK
285+
return resp, nil
286+
}, func(op *huma.Operation) {
287+
op.OperationID = "system-page-title-settings-get"
288+
op.Summary = "获取网页标题设置"
289+
op.Description = "返回服务端实例级浏览器标签页标题,未登录时也可读取"
290+
op.Tags = []string{systemTag}
291+
})
292+
293+
huma.Post(group, "/system/page-title-settings/update", func(ctx context.Context, input *struct {
294+
Body pageTitleSettings `json:"body"`
295+
}) (*h.ItemResponse[pageTitleSettings], error) {
296+
title, err := utils.NormalizePageTitle(input.Body.Title)
297+
if err != nil {
298+
return nil, huma.Error400BadRequest(err.Error())
299+
}
300+
if err := utils.UpdateConfig(cfg, func(c *utils.AppConfig) {
301+
c.UI.PageTitle = title
302+
}); err != nil {
303+
return nil, huma.Error500InternalServerError("failed to save configuration")
304+
}
305+
306+
resp := h.NewItemResponse(pageTitleSettings{Title: title})
307+
resp.Status = http.StatusOK
308+
return resp, nil
309+
}, func(op *huma.Operation) {
310+
op.OperationID = "system-page-title-settings-update"
311+
op.Summary = "更新网页标题设置"
312+
op.Description = "更新实例级浏览器标签页标题,并持久化到配置文件"
313+
op.Tags = []string{systemTag}
314+
})
315+
276316
huma.Post(group, "/system/daily-tip-settings/update", func(ctx context.Context, input *struct {
277317
Body dailyTipSettings `json:"body"`
278318
}) (*h.ItemResponse[dailyTipSettings], error) {
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
package api
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"io"
7+
"net/http"
8+
"os"
9+
"strings"
10+
"testing"
11+
12+
"code-kanban/utils"
13+
14+
"gopkg.in/yaml.v3"
15+
)
16+
17+
type pageTitleSettingsResponse struct {
18+
Item pageTitleSettings `json:"item"`
19+
}
20+
21+
func TestSystemPageTitleSettingsGetReturnsServerValue(t *testing.T) {
22+
cfg, _ := loadSystemDailyTipTestConfig(t, `
23+
ui:
24+
pageTitle: Staging Board
25+
`)
26+
if got := cfg.UI.PageTitle; got != "Staging Board" {
27+
t.Fatalf("configured title = %q, want %q", got, "Staging Board")
28+
}
29+
app := newSystemDailyTipTestApp(t, cfg)
30+
31+
resp := mustSystemDailyTipTestRequest(t, app, http.MethodGet, "/api/v1/system/page-title-settings", nil)
32+
if resp.StatusCode != http.StatusOK {
33+
t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusOK)
34+
}
35+
36+
rawBody, err := io.ReadAll(resp.Body)
37+
if err != nil {
38+
t.Fatalf("read response failed: %v", err)
39+
}
40+
var payload pageTitleSettingsResponse
41+
if err := json.Unmarshal(rawBody, &payload); err != nil {
42+
t.Fatalf("decode response failed: %v; body=%s", err, string(rawBody))
43+
}
44+
if got := payload.Item.Title; got != "Staging Board" {
45+
t.Fatalf("title = %q, want %q; body=%s", got, "Staging Board", string(rawBody))
46+
}
47+
}
48+
49+
func TestSystemPageTitleSettingsUpdateNormalizesAndPersistsConfig(t *testing.T) {
50+
cfg, configPath := loadSystemDailyTipTestConfig(t, `
51+
ui:
52+
pageTitle: Code Kanban
53+
`)
54+
app := newSystemDailyTipTestApp(t, cfg)
55+
56+
body, err := json.Marshal(pageTitleSettings{Title: " 工作实例 🚀 "})
57+
if err != nil {
58+
t.Fatalf("marshal request failed: %v", err)
59+
}
60+
resp := mustSystemDailyTipTestRequest(
61+
t,
62+
app,
63+
http.MethodPost,
64+
"/api/v1/system/page-title-settings/update",
65+
bytes.NewBuffer(body),
66+
)
67+
if resp.StatusCode != http.StatusOK {
68+
rawBody, _ := io.ReadAll(resp.Body)
69+
t.Fatalf("status = %d, want %d; body=%s", resp.StatusCode, http.StatusOK, string(rawBody))
70+
}
71+
if got := cfg.UI.PageTitle; got != "工作实例 🚀" {
72+
t.Fatalf("in-memory title = %q, want %q", got, "工作实例 🚀")
73+
}
74+
75+
rewritten, err := os.ReadFile(configPath)
76+
if err != nil {
77+
t.Fatalf("read config failed: %v", err)
78+
}
79+
var persisted utils.AppConfig
80+
if err := yaml.Unmarshal(rewritten, &persisted); err != nil {
81+
t.Fatalf("parse persisted config failed: %v", err)
82+
}
83+
if got := persisted.UI.PageTitle; got != "工作实例 🚀" {
84+
t.Fatalf("persisted title = %q, want %q", got, "工作实例 🚀")
85+
}
86+
}
87+
88+
func TestSystemPageTitleSettingsUpdateBlankRestoresDefault(t *testing.T) {
89+
cfg, _ := loadSystemDailyTipTestConfig(t, `
90+
ui:
91+
pageTitle: Staging Board
92+
`)
93+
app := newSystemDailyTipTestApp(t, cfg)
94+
95+
resp := mustSystemDailyTipTestRequest(
96+
t,
97+
app,
98+
http.MethodPost,
99+
"/api/v1/system/page-title-settings/update",
100+
bytes.NewBufferString(`{"title":" "}`),
101+
)
102+
if resp.StatusCode != http.StatusOK {
103+
t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusOK)
104+
}
105+
if got := cfg.UI.PageTitle; got != utils.DefaultPageTitle {
106+
t.Fatalf("title = %q, want %q", got, utils.DefaultPageTitle)
107+
}
108+
}
109+
110+
func TestSystemPageTitleSettingsUpdateRejectsInvalidTitles(t *testing.T) {
111+
tests := []struct {
112+
name string
113+
title string
114+
}{
115+
{name: "control character", title: "Bad\nTitle"},
116+
{name: "too long", title: strings.Repeat("界", utils.MaxPageTitleRunes+1)},
117+
}
118+
119+
for _, tt := range tests {
120+
t.Run(tt.name, func(t *testing.T) {
121+
cfg, _ := loadSystemDailyTipTestConfig(t, `
122+
ui:
123+
pageTitle: Original
124+
`)
125+
app := newSystemDailyTipTestApp(t, cfg)
126+
body, err := json.Marshal(pageTitleSettings{Title: tt.title})
127+
if err != nil {
128+
t.Fatalf("marshal request failed: %v", err)
129+
}
130+
131+
resp := mustSystemDailyTipTestRequest(
132+
t,
133+
app,
134+
http.MethodPost,
135+
"/api/v1/system/page-title-settings/update",
136+
bytes.NewBuffer(body),
137+
)
138+
if resp.StatusCode != http.StatusBadRequest {
139+
rawBody, _ := io.ReadAll(resp.Body)
140+
t.Fatalf("status = %d, want %d; body=%s", resp.StatusCode, http.StatusBadRequest, string(rawBody))
141+
}
142+
if got := cfg.UI.PageTitle; got != "Original" {
143+
t.Fatalf("invalid update changed title to %q", got)
144+
}
145+
})
146+
}
147+
}
148+
149+
func TestPageTitleSettingsReadIsAnonymousButUpdateIsProtected(t *testing.T) {
150+
cfg := newAuthTestConfig()
151+
if !isAnonymousPath("/api/v1/system/page-title-settings", cfg) {
152+
t.Fatal("expected page title read endpoint to allow anonymous access")
153+
}
154+
if isAnonymousPath("/api/v1/system/page-title-settings/update", cfg) {
155+
t.Fatal("expected page title update endpoint to require protected access")
156+
}
157+
}

api/system_settings_backup.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,11 @@ func addPayloadSections(result *utils.SettingsBackupPreviewResult, backup utils.
203203
ChangedKeys: []string{"enableTerminalScrollback", "renameSessionTitleEachCommand", "enableTerminalStateSnapshot", "webSessionCodexDefaultSyncMode", "webSessionActiveCallTimeout"},
204204
})
205205
}
206+
if server.PageTitle != nil {
207+
result.Sections = append(result.Sections, utils.SettingsBackupPreviewSection{
208+
Key: "server.pageTitle", Label: "Page title", Action: "replace", Target: "server", ChangedKeys: []string{"pageTitle"},
209+
})
210+
}
206211
if server.DailyTip != nil {
207212
result.Sections = append(result.Sections, utils.SettingsBackupPreviewSection{
208213
Key: "server.dailyTip", Label: "Daily tip", Action: "replace", Target: "server", ChangedKeys: []string{"enabled"},
@@ -260,6 +265,16 @@ func validateServerPayload(result *utils.SettingsBackupPreviewResult, payload *u
260265
sanitized := utils.SanitizeAuthAccessConfig(*payload.AuthAccess)
261266
payload.AuthAccess = &sanitized
262267
}
268+
if payload.PageTitle != nil {
269+
pageTitle, err := utils.NormalizePageTitle(*payload.PageTitle)
270+
if err != nil {
271+
result.Errors = append(result.Errors, utils.SettingsBackupPreviewIssue{
272+
Code: "invalid_page_title", Level: "error", Message: err.Error(),
273+
})
274+
} else {
275+
payload.PageTitle = &pageTitle
276+
}
277+
}
263278

264279
if payload.TerminalShell != nil &&
265280
payload.TerminalShell.Platform != "" &&
@@ -335,6 +350,13 @@ func applySettingsBackup(
335350
developer := utils.NormalizeDeveloperConfig(*server.Developer)
336351
server.Developer = &developer
337352
}
353+
if server.PageTitle != nil {
354+
pageTitle, err := utils.NormalizePageTitle(*server.PageTitle)
355+
if err != nil {
356+
return err
357+
}
358+
server.PageTitle = &pageTitle
359+
}
338360
server.WebSessionQuickInput = utils.NormalizeSettingsBackupQuickInputSection(server.WebSessionQuickInput)
339361
if server.AuthAccess != nil {
340362
authAccess, err := utils.NormalizeAuthAccessConfig(*server.AuthAccess)
@@ -351,6 +373,9 @@ func applySettingsBackup(
351373
if server.Developer != nil {
352374
c.Developer = *server.Developer
353375
}
376+
if server.PageTitle != nil {
377+
c.UI.PageTitle = *server.PageTitle
378+
}
354379
if server.DailyTip != nil {
355380
c.UI.DailyTipEnabled = server.DailyTip.Enabled
356381
}

api/system_settings_backup_test.go

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ func (s *settingsBackupWebSessionManagerStub) RefreshDeveloperConfig() {
5959
func TestSystemSettingsBackupExportReturnsServerPayload(t *testing.T) {
6060
cfg, _ := loadSystemSettingsBackupTestConfig(t, `
6161
ui:
62+
pageTitle: Staging Board
6263
dailyTipEnabled: false
6364
webSessionQuickInput:
6465
pinned: ["Ship"]
@@ -100,6 +101,9 @@ terminal:
100101
if item.Payload.Server.DailyTip == nil || item.Payload.Server.DailyTip.Enabled {
101102
t.Fatal("expected exported daily tip to be false")
102103
}
104+
if item.Payload.Server.PageTitle == nil || *item.Payload.Server.PageTitle != "Staging Board" {
105+
t.Fatalf("expected exported page title, got %#v", item.Payload.Server.PageTitle)
106+
}
103107
if item.Payload.Server.TerminalShell == nil {
104108
t.Fatal("expected terminal shell payload")
105109
}
@@ -231,9 +235,51 @@ func TestSystemSettingsBackupPreviewRejectsInvalidShell(t *testing.T) {
231235
}
232236
}
233237

238+
func TestSystemSettingsBackupPreviewRejectsInvalidPageTitle(t *testing.T) {
239+
cfg, _ := loadSystemSettingsBackupTestConfig(t, "")
240+
app := newSystemSettingsBackupTestApp(t, cfg, nil, nil)
241+
backup := utils.SettingsBackupFile{
242+
BackupSchemaVersion: utils.SettingsBackupSchemaVersion,
243+
BackupKind: utils.SettingsBackupKind,
244+
SourceApp: utils.SettingsBackupSourceApp{Name: "Code Kanban", Version: "1.0.0", Channel: "stable"},
245+
Payload: utils.SettingsBackupPayload{
246+
Server: &utils.SettingsBackupServerPayload{
247+
PageTitle: loPtr(strings.Repeat("界", utils.MaxPageTitleRunes+1)),
248+
},
249+
},
250+
}
251+
252+
body, err := json.Marshal(backup)
253+
if err != nil {
254+
t.Fatalf("marshal backup failed: %v", err)
255+
}
256+
resp := mustSystemSettingsBackupRequest(
257+
t,
258+
app,
259+
http.MethodPost,
260+
"/api/v1/system/settings-backup/preview",
261+
bytes.NewBuffer(body),
262+
)
263+
if resp.StatusCode != http.StatusOK {
264+
t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusOK)
265+
}
266+
267+
var payload settingsBackupResponseEnvelope[utils.SettingsBackupPreviewResult]
268+
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
269+
t.Fatalf("decode response failed: %v", err)
270+
}
271+
if payload.Item.CanImport {
272+
t.Fatal("expected invalid page title to block import")
273+
}
274+
if len(payload.Item.Errors) != 1 || payload.Item.Errors[0].Code != "invalid_page_title" {
275+
t.Fatalf("expected invalid_page_title error, got %#v", payload.Item.Errors)
276+
}
277+
}
278+
234279
func TestSystemSettingsBackupImportAppliesConfigAndHotReloads(t *testing.T) {
235280
cfg, configPath := loadSystemSettingsBackupTestConfig(t, `
236281
ui:
282+
pageTitle: Original
237283
dailyTipEnabled: true
238284
webSessionQuickInput:
239285
pinned: ["continue"]
@@ -256,6 +302,7 @@ terminal:
256302
SourceApp: utils.SettingsBackupSourceApp{Name: "Code Kanban", Version: "1.0.0", Channel: "stable"},
257303
Payload: utils.SettingsBackupPayload{
258304
Server: &utils.SettingsBackupServerPayload{
305+
PageTitle: loPtr("Imported Board"),
259306
AIAssistantStatus: loPtr(utils.AIAssistantStatusConfig{
260307
ClaudeCode: false,
261308
Codex: false,
@@ -324,6 +371,9 @@ terminal:
324371
if cfg.UI.DailyTipEnabled {
325372
t.Fatal("expected daily tip to be disabled after import")
326373
}
374+
if got := cfg.UI.PageTitle; got != "Imported Board" {
375+
t.Fatalf("page title = %q, want Imported Board", got)
376+
}
327377
if !cfg.Developer.EnableTerminalScrollback || !cfg.Developer.RenameSessionTitleEachCommand {
328378
t.Fatalf("developer config not applied: %#v", cfg.Developer)
329379
}
@@ -348,6 +398,11 @@ terminal:
348398
if !strings.Contains(content, "dailyTipEnabled: false") {
349399
t.Fatalf("expected config file rewrite, got:\n%s", content)
350400
}
401+
if !strings.Contains(content, "pageTitle: Imported Board") &&
402+
!strings.Contains(content, "pageTitle: 'Imported Board'") &&
403+
!strings.Contains(content, `pageTitle: "Imported Board"`) {
404+
t.Fatalf("expected imported page title in config, got:\n%s", content)
405+
}
351406
if !strings.Contains(content, "globalDirNamePattern: '{projectName}-custom'") &&
352407
!strings.Contains(content, "globalDirNamePattern: \"{projectName}-custom\"") &&
353408
!strings.Contains(content, "globalDirNamePattern: {projectName}-custom") {
@@ -387,6 +442,7 @@ func TestSystemSettingsBackupPreviewRejectsLegacySchemaV1(t *testing.T) {
387442
func TestSystemSettingsBackupImportAppliesOnlySelectedServerSections(t *testing.T) {
388443
cfg, _ := loadSystemSettingsBackupTestConfig(t, `
389444
ui:
445+
pageTitle: Original
390446
dailyTipEnabled: true
391447
webSessionQuickInput:
392448
pinned: ["continue"]
@@ -437,6 +493,9 @@ terminal:
437493
if cfg.UI.DailyTipEnabled {
438494
t.Fatal("expected daily tip to be updated")
439495
}
496+
if got := cfg.UI.PageTitle; got != "Original" {
497+
t.Fatalf("page title should remain unchanged, got %q", got)
498+
}
440499
if got := cfg.UI.WebSessionQuickInput.Pinned; len(got) != 1 || got[0] != "Plan" {
441500
t.Fatalf("unexpected pinned quick input: %#v", got)
442501
}

0 commit comments

Comments
 (0)