Skip to content

Commit fe73eef

Browse files
authored
feat(settings): add environments settings (#1361)
* feat(settings): add environments settings * fix(settings): harden environments handling
1 parent dbbeb36 commit fe73eef

51 files changed

Lines changed: 2615 additions & 99 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
# Settings Environments Plan
2+
3+
## Data Model
4+
5+
- Add `new_environments` with:
6+
- `path TEXT PRIMARY KEY`
7+
- `session_count INTEGER NOT NULL`
8+
- `last_used_at INTEGER NOT NULL`
9+
- Add index `idx_new_environments_last_used` on `last_used_at DESC`.
10+
- Add schema migration `v17` to create the table.
11+
- Add schema migration `v18` to rebuild environment history from:
12+
- non-draft `new_sessions.project_dir`
13+
- ACP `workdir` rows when the linked session has no `project_dir`
14+
15+
## Synchronization Strategy
16+
17+
- Keep `new_environments` as a derived table managed in application code.
18+
- `NewSessionManager` recomputes all affected paths after session create, update, and delete.
19+
- ACP session persistence updates also recompute affected environment paths when `workdir` changes.
20+
- `LegacyChatImportService` performs a full rebuild after import because it writes session rows directly.
21+
- `SQLitePresenter.clearNewAgentData()` clears `new_environments` together with session-domain tables.
22+
23+
## Presenter / IPC
24+
25+
- Extend `IProjectPresenter` with:
26+
- `getEnvironments()`
27+
- `openDirectory(path)`
28+
- Extend `IConfigPresenter` with:
29+
- `getDefaultProjectPath()`
30+
- `setDefaultProjectPath(path | null)`
31+
- Add `CONFIG_EVENTS.DEFAULT_PROJECT_PATH_CHANGED` so renderers can react to default directory updates.
32+
33+
## Renderer
34+
35+
- Add `EnvironmentsSettings.vue` under settings routes.
36+
- Reuse current settings card layout and controls.
37+
- Use a switch to control temp directory visibility.
38+
- Use the project store to:
39+
- fetch environment history
40+
- manage the default project path
41+
- open directories
42+
- Extend the project store with:
43+
- `environments`
44+
- `defaultProjectPath`
45+
- selection-source tracking so default selection does not override manual selection
46+
- synthetic project injection for missing default/manual paths
47+
48+
## Sorting and Grouping
49+
50+
- Sort by:
51+
- default directory first
52+
- then `lastUsedAt DESC`
53+
- Render as one main list, with temp environments appended in a collapsed section.
54+
- Treat app-managed workspace roots, including legacy app-data `workspaces` paths, as temp entries.
55+
56+
## Test Strategy
57+
58+
- Main process:
59+
- migration `v18` backfill and idempotency
60+
- `NewEnvironmentsTable` single-path recompute, ACP `workdir` fallback, draft filtering, delete-on-empty, full rebuild
61+
- `ProjectPresenter` environment mapping and directory open behavior
62+
- `NewSessionManager` environment sync calls
63+
- `LegacyChatImportService` rebuild trigger and `workdir` import fallback
64+
- Renderer:
65+
- settings page rendering, temp switch collapse, open/default/clear actions
66+
- project store default selection and synthetic project behavior
67+
- new thread page consumes the preselected project path
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# Settings Environments
2+
3+
## Summary
4+
5+
Add a new settings page, `Environments / 目录环境`, to show project directories that have actually been used by sessions. Users can review basic metadata, open a directory, and set or clear a default directory for new chats.
6+
7+
## User Stories
8+
9+
- As a user, I want to see which project directories my sessions have used so I can reopen them quickly.
10+
- As a user, I want to set one default directory for new chats without overriding a directory I select manually later.
11+
- As a user, I want temporary workspace directories separated from regular directories so the primary list stays clean.
12+
13+
## Acceptance Criteria
14+
15+
- Settings navigation includes `settings-environments` after `Display` and before provider settings.
16+
- The page shows:
17+
- a single merged environments list
18+
- a temp environments section collapsed by default and controlled by a switch
19+
- Environment entries come from directories used by non-draft sessions, using `new_sessions.project_dir` first and falling back to ACP `workdir` history when `project_dir` is missing.
20+
- Each entry shows:
21+
- directory name
22+
- full path
23+
- session count
24+
- last used time based on `new_sessions.updated_at`
25+
- badges for default, temp, missing, and synthetic default states when applicable
26+
- Users can:
27+
- open a directory with one click
28+
- set a directory as default directly on the item
29+
- clear the current default directory directly on the default item
30+
- Temp directories are determined by whether the path is under `app.getPath('temp')` or an app-managed workspace root under app data / legacy user data.
31+
- Default directory only preselects the project on the new thread page. Manual user selection remains higher priority.
32+
- If the default directory is not present in recent projects, the new thread page still surfaces it through a synthetic project entry.
33+
34+
## Non-Goals
35+
36+
- Git status, rename, delete, or add-environment actions
37+
- Tracking directories that were only opened in a picker without being used by a session
38+
- Trigger-based SQLite maintenance
39+
40+
## Constraints
41+
42+
- Follow existing settings visual language instead of reproducing the reference screenshot.
43+
- Use i18n for all user-facing strings.
44+
- Use a derived SQLite table for environment history to avoid scanning `new_sessions` on every page load.
45+
46+
## Migration Notes
47+
48+
- Introduce `new_environments` as a derived table.
49+
- Backfill it once from existing session history in schema migration `v18`, including ACP `workdir` fallback when `project_dir` is absent.
50+
- Keep it synchronized in application code when sessions change.

src/main/events.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,8 @@ export const CONFIG_EVENTS = {
4242
FONT_SIZE_CHANGED: 'config:font-size-changed', // 字体大小变更事件
4343
DEFAULT_SYSTEM_PROMPT_CHANGED: 'config:default-system-prompt-changed', // Default system prompt changed event
4444
CUSTOM_PROMPTS_CHANGED: 'config:custom-prompts-changed', // 自定义提示词变更事件
45-
NOWLEDGE_MEM_CONFIG_UPDATED: 'config:nowledge-mem-config-updated' // Nowledge-mem configuration updated event
45+
NOWLEDGE_MEM_CONFIG_UPDATED: 'config:nowledge-mem-config-updated', // Nowledge-mem configuration updated event
46+
DEFAULT_PROJECT_PATH_CHANGED: 'config:default-project-path-changed'
4647
}
4748

4849
// Provider DB(聚合 JSON)相关事件

src/main/presenter/configPresenter/index.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ interface IAppSettings {
9898
hooksNotifications?: HooksNotificationsSettings // Hooks & notifications settings
9999
defaultModel?: { providerId: string; modelId: string } // Default model for new conversations
100100
defaultVisionModel?: { providerId: string; modelId: string } // Default vision model for image tools
101+
defaultProjectPath?: string | null
101102
[key: string]: unknown // Allow arbitrary keys, using unknown type instead of any
102103
}
103104

@@ -1867,6 +1868,19 @@ export class ConfigPresenter implements IConfigPresenter {
18671868
setDefaultVisionModel(model: { providerId: string; modelId: string } | undefined): void {
18681869
this.setSetting('defaultVisionModel', model)
18691870
}
1871+
1872+
getDefaultProjectPath(): string | null {
1873+
const path = this.getSetting<string | null>('defaultProjectPath')
1874+
return path?.trim() ? path.trim() : null
1875+
}
1876+
1877+
setDefaultProjectPath(projectPath: string | null): void {
1878+
const normalized = projectPath?.trim() ? projectPath.trim() : null
1879+
this.setSetting('defaultProjectPath', normalized)
1880+
eventBus.sendToRenderer(CONFIG_EVENTS.DEFAULT_PROJECT_PATH_CHANGED, SendTarget.ALL_WINDOWS, {
1881+
path: normalized
1882+
})
1883+
}
18701884
}
18711885

18721886
export { defaultShortcutKey } from './shortcutKeySettings'

src/main/presenter/index.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -285,10 +285,12 @@ export class Presenter implements IPresenter {
285285
(
286286
this.sqlitePresenter as unknown as import('./sqlitePresenter').SQLitePresenter
287287
).newSessionsTable?.getActiveSkills(conversationId) ?? [],
288-
setPersistedNewSessionSkills: (conversationId, skills) =>
289-
(
290-
this.sqlitePresenter as unknown as import('./sqlitePresenter').SQLitePresenter
291-
).newSessionsTable?.updateActiveSkills(conversationId, skills),
288+
setPersistedNewSessionSkills: (conversationId, skills) => {
289+
const sqlitePresenter = this
290+
.sqlitePresenter as unknown as import('./sqlitePresenter').SQLitePresenter
291+
sqlitePresenter.newSessionsTable?.updateActiveSkills(conversationId, skills)
292+
sqlitePresenter.newEnvironmentsTable?.syncForSession(conversationId)
293+
},
292294
repairImportedLegacySessionSkills: async (conversationId) => {
293295
const newAgentPresenter = this.newAgentPresenter as INewAgentPresenter & {
294296
repairImportedLegacySessionSkills?: (sessionId: string) => Promise<string[]>

src/main/presenter/newAgentPresenter/legacyImportService.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -368,7 +368,7 @@ export class LegacyChatImportService {
368368
this.pickString(conversation, ['active_skills']) ?? ''
369369
)
370370

371-
let projectDir = this.pickString(conversation, ['agent_workspace_path']) ?? null
371+
let projectDir = this.pickString(conversation, ['agent_workspace_path', 'workdir']) ?? null
372372
if (!projectDir && agentId !== 'deepchat') {
373373
const workdirMap = this.parseJsonRecord(
374374
this.pickString(conversation, ['acp_workdir_map']) ?? ''
@@ -535,6 +535,15 @@ export class LegacyChatImportService {
535535
}
536536
}
537537
})
538+
try {
539+
// newEnvironmentsTable.rebuildFromSessions only refreshes derived environment metadata.
540+
this.sqlitePresenter.newEnvironmentsTable.rebuildFromSessions()
541+
} catch (error) {
542+
console.error('[LegacyChatImport] Failed to rebuild environments after import:', {
543+
error,
544+
message: error instanceof Error ? error.message : String(error)
545+
})
546+
}
538547

539548
return {
540549
importedSessions,

src/main/presenter/newAgentPresenter/sessionManager.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export class NewSessionManager {
2222
isDraft: options?.isDraft,
2323
disabledAgentTools: options?.disabledAgentTools
2424
})
25+
this.sqlitePresenter.newEnvironmentsTable.syncPath(projectDir)
2526
return id
2627
}
2728

@@ -58,6 +59,13 @@ export class NewSessionManager {
5859
id: string,
5960
fields: Partial<Pick<SessionRecord, 'title' | 'projectDir' | 'isPinned' | 'isDraft'>>
6061
): void {
62+
const current = this.sqlitePresenter.newSessionsTable.get(id)
63+
if (!current) {
64+
return
65+
}
66+
67+
const affectedPaths = new Set(this.sqlitePresenter.newEnvironmentsTable.listPathsForSession(id))
68+
6169
const dbFields: {
6270
title?: string
6371
project_dir?: string | null
@@ -69,10 +77,22 @@ export class NewSessionManager {
6977
if (fields.isPinned !== undefined) dbFields.is_pinned = fields.isPinned ? 1 : 0
7078
if (fields.isDraft !== undefined) dbFields.is_draft = fields.isDraft ? 1 : 0
7179
this.sqlitePresenter.newSessionsTable.update(id, dbFields)
80+
81+
for (const path of this.sqlitePresenter.newEnvironmentsTable.listPathsForSession(id)) {
82+
affectedPaths.add(path)
83+
}
84+
85+
for (const path of affectedPaths) {
86+
this.sqlitePresenter.newEnvironmentsTable.syncPath(path)
87+
}
7288
}
7389

7490
delete(id: string): void {
91+
const affectedPaths = this.sqlitePresenter.newEnvironmentsTable.listPathsForSession(id)
7592
this.sqlitePresenter.newSessionsTable.delete(id)
93+
for (const path of affectedPaths) {
94+
this.sqlitePresenter.newEnvironmentsTable.syncPath(path)
95+
}
7696
}
7797

7898
getDisabledAgentTools(id: string): string[] {
@@ -81,6 +101,7 @@ export class NewSessionManager {
81101

82102
updateDisabledAgentTools(id: string, disabledAgentTools: string[]): void {
83103
this.sqlitePresenter.newSessionsTable.updateDisabledAgentTools(id, disabledAgentTools)
104+
this.sqlitePresenter.newEnvironmentsTable.syncForSession(id)
84105
}
85106

86107
// Window binding management

src/main/presenter/projectPresenter/index.ts

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,23 @@
1+
import { app, shell } from 'electron'
2+
import fs from 'fs'
13
import path from 'path'
24
import type { IDevicePresenter } from '@shared/presenter'
35
import type { SQLitePresenter } from '../sqlitePresenter'
4-
import type { Project } from '@shared/types/agent-interface'
6+
import type { EnvironmentSummary, Project } from '@shared/types/agent-interface'
57

68
export class ProjectPresenter {
79
private sqlitePresenter: SQLitePresenter
810
private devicePresenter: IDevicePresenter
11+
private readonly tempRoot: string
12+
private readonly userDataWorkspacesRoot: string
13+
private readonly appDataRoot: string
914

1015
constructor(sqlitePresenter: SQLitePresenter, devicePresenter: IDevicePresenter) {
1116
this.sqlitePresenter = sqlitePresenter
1217
this.devicePresenter = devicePresenter
18+
this.tempRoot = path.resolve(app.getPath('temp'))
19+
this.userDataWorkspacesRoot = path.resolve(path.join(app.getPath('userData'), 'workspaces'))
20+
this.appDataRoot = path.resolve(app.getPath('appData'))
1321
}
1422

1523
async getProjects(): Promise<Project[]> {
@@ -32,6 +40,39 @@ export class ProjectPresenter {
3240
}))
3341
}
3442

43+
async getEnvironments(): Promise<EnvironmentSummary[]> {
44+
const rows = this.sqlitePresenter.newEnvironmentsTable.list()
45+
return rows.map((row) => ({
46+
path: row.path,
47+
name: path.basename(row.path) || row.path,
48+
sessionCount: row.session_count,
49+
lastUsedAt: row.last_used_at,
50+
isTemp: this.isTempPath(row.path),
51+
exists: fs.existsSync(row.path)
52+
}))
53+
}
54+
55+
async pathExists(targetPath: string): Promise<boolean> {
56+
const normalizedPath = targetPath?.trim()
57+
if (!normalizedPath) {
58+
return false
59+
}
60+
61+
return fs.existsSync(normalizedPath)
62+
}
63+
64+
async openDirectory(dirPath: string): Promise<void> {
65+
const normalizedPath = dirPath?.trim()
66+
if (!normalizedPath) {
67+
return
68+
}
69+
70+
const errorMessage = await shell.openPath(normalizedPath)
71+
if (errorMessage) {
72+
throw new Error(errorMessage)
73+
}
74+
}
75+
3576
async selectDirectory(): Promise<string | null> {
3677
const result = await this.devicePresenter.selectDirectory()
3778
if (result.canceled || result.filePaths.length === 0) return null
@@ -42,4 +83,38 @@ export class ProjectPresenter {
4283
this.sqlitePresenter.newProjectsTable.upsert(dirPath, dirName)
4384
return dirPath
4485
}
86+
87+
private isTempPath(projectPath: string): boolean {
88+
const normalized = projectPath?.trim()
89+
if (!normalized) {
90+
return false
91+
}
92+
93+
const resolvedPath = path.resolve(normalized)
94+
return (
95+
this.isWithinRoot(resolvedPath, this.tempRoot) ||
96+
this.isWithinRoot(resolvedPath, this.userDataWorkspacesRoot) ||
97+
this.isAppManagedWorkspacePath(resolvedPath)
98+
)
99+
}
100+
101+
private isWithinRoot(targetPath: string, rootPath: string): boolean {
102+
const relative = path.relative(rootPath, targetPath)
103+
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative))
104+
}
105+
106+
private isAppManagedWorkspacePath(targetPath: string): boolean {
107+
const workspaceMarker = `${path.sep}workspaces`
108+
const markerIndex = targetPath.indexOf(workspaceMarker)
109+
if (markerIndex < 0) {
110+
return false
111+
}
112+
113+
const appContainerPath = targetPath.slice(0, markerIndex)
114+
if (!appContainerPath) {
115+
return false
116+
}
117+
118+
return this.isWithinRoot(appContainerPath, this.appDataRoot)
119+
}
45120
}

0 commit comments

Comments
 (0)