Skip to content

Commit 08eb77b

Browse files
committed
[C4] Restore last session (reopen recent project & last document)
1 parent 634eee2 commit 08eb77b

File tree

6 files changed

+212
-7
lines changed

6 files changed

+212
-7
lines changed

issues/2026-02-10_18-23-04-ui-autosave-streaming-v1.csv

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ ID,Title,Description,Acceptance,Test_Method,Tools,Dev_Status,Review1_Status,Regr
22
C1,Unify bottom status UI (remove duplicate editor status frame),Remove EditorPanel internal bottom status frame to avoid double bottom bars and overlapping lines; ensure stats/cursor/doc status still update via main EnhancedStatusBar; adjust layout/styling if needed; update Qt layout test accordingly.,Only one bottom status area is shown; no overlapping/stacked lines; status info still updates; Qt layout test passes.,python -m pytest -q tests/test_status_bar_layout.py,none,DONE,DONE,TODO,src/gui/editor/editor_panel.py | src/gui/status/status_bar.py | tests/test_status_bar_layout.py,none,done_at:2026-02-10 | test:.tmp\\ane0305-venv-311\\Scripts\\python.exe -m pytest -q tests/test_status_bar_layout.py
33
C2,Add ProjectManager.update_document_content + editor save,Implement ProjectManager.update_document_content wrapper (persist content via update_document); update IntelligentTextEditor manual save and autosave paths to persist when bound; add unit test covering update_document_content behavior and ensuring no AttributeError/crash.,Autosave/manual save persist content via ProjectManager.update_document_content when project/doc bound; unit test passes.,python -m pytest -q tests/test_project_update_document_content.py,none,DONE,DONE,TODO,src/core/project.py | src/gui/editor/text_editor.py | tests/test_project_update_document_content.py,none,done_at:2026-02-10 | test:.tmp\\ane0305-venv-311\\Scripts\\python.exe -m pytest -q tests/test_project_update_document_content.py
44
C3,Bind EditorPanel documents to ProjectManager (autosave works),Add EditorPanel.set_project_manager and ensure all editors created for project documents are bound to the active ProjectManager + correct document_id; update MainWindow wiring so selecting a document loads/updates via ProjectManager and manual save works; avoid users typing into an unbound scratch doc when a project is open; add integration test.,Editing a project document triggers autosave and persists into project DB; reopening project shows saved content; manual save works; integration test passes.,python -m pytest -q tests/test_editor_project_persistence.py,none,DONE,DONE,TODO,src/gui/editor/editor_panel.py | src/gui/main_window_parts/ui.py | src/gui/main_window_parts/integrations.py | tests/test_editor_project_persistence.py,C2,test:pytest -q tests/test_editor_project_persistence.py PASS | done_at:2026-02-10
5-
C4,Restore last session (reopen recent project & last document),"Persist and restore last-open project/document when enabled: wire SettingsDialog restore_session/auto_save prefs into Config; on startup, if enabled, auto-open most recent project and re-open last document (fallback to first scene).",Restart restores the previous project and last document content instead of showing a blank scratch doc (when restore_session enabled).,manual,none,TODO,TODO,TODO,src/main.py | src/core/config_schema.py | src/gui/dialogs/settings_dialog.py | src/core/config.py | src/gui/main_window_parts/ui.py | src/gui/main_window_parts/integrations.py,C3,"manual_checklist: 1) Create/open a project, edit a scene, wait for autosave, close app. 2) Reopen app; expect project auto-open and edited content present. 3) Disable restore_session in settings; restart; expect no auto-open."
5+
C4,Restore last session (reopen recent project & last document),"Persist and restore last-open project/document when enabled: wire SettingsDialog restore_session/auto_save prefs into Config; on startup, if enabled, auto-open most recent project and re-open last document (fallback to first scene).",Restart restores the previous project and last document content instead of showing a blank scratch doc (when restore_session enabled).,manual,none,DONE,DONE,TODO,src/main.py | src/core/config_schema.py | src/gui/dialogs/settings_dialog.py | src/core/config.py | src/gui/main_window_parts/ui.py | src/gui/main_window_parts/integrations.py | src/gui/main_window_parts/dialogs.py | src/gui/editor/text_editor.py,C3,"manual_checklist: 1) Create/open a project, edit a scene, wait for autosave, close app. 2) Reopen app; expect project auto-open and edited content present. 3) Disable restore_session in settings; restart; expect no auto-open. | validation_limited:manual app restart not executed here | evidence:python -m compileall -q src PASS | risk:medium session-restore depends on runtime restart flow | done_at:2026-02-10"
66
C5,Streaming dispatch respects stream_response toggle,Plumb config ai.stream_response (default on) through AICompletionService/AIRequestDispatcher to use QtAIClient.complete_stream_async; include context/request_id in chunk signals; ensure cancellation prevents stale chunks from updating UI; add unit test for dispatch selection.,"When stream_response enabled, streaming client method is used and chunk signals are emitted; when disabled, non-stream method used; unit test passes.",python -m pytest -q tests/test_streaming_dispatcher.py,none,TODO,TODO,TODO,src/application/ai_completion_service.py | src/core/ai_qt_client.py | src/gui/ai/enhanced_ai_manager.py | src/gui/main_window_parts/integrations.py | tests/test_streaming_dispatcher.py,none,none
77
C6,Provider streaming adaptation (Gemini endpoint + chunk parsing),Implement provider-specific streaming endpoint/headers/parsing: Gemini uses streamGenerateContent + SSE; add Gemini extract_stream_content; improve streaming parser robustness; ensure OpenAI/Claude continue working; add unit tests with fixture chunks.,Gemini streaming endpoint/path is correct and chunk parsing yields incremental text; unit tests pass.,python -m pytest -q tests/test_provider_streaming_gemini.py,none,TODO,TODO,TODO,src/core/ai_client.py | src/core/ai_providers/gemini.py | src/core/ai_providers/openai.py | src/core/ai_providers/claude.py | tests/test_provider_streaming_gemini.py,C5,none
88
C7,UI streaming renderer (typed sequential ghost text; no乱序),"Connect streaming chunk updates to SmartCompletionManager and render as ordered, typing-like ghost text; buffer and flush to UI to avoid乱序/抖动; ignore chunks from cancelled/old requests; finalize cleanly on completion; add unit/Qt test.","During streaming completion, suggestion grows in order like typing; cancel stops updates; no stale chunks applied; test passes.",python -m pytest -q tests/test_streaming_typed_renderer.py,none,TODO,TODO,TODO,src/gui/editor/smart_completion_manager.py | src/gui/main_window_parts/integrations.py | src/gui/ai/enhanced_ai_manager.py | tests/test_streaming_typed_renderer.py,C5|C6,none

src/core/config_schema.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@
1111
# 应用程序设置
1212
"app": {
1313
"language": "zh_CN",
14+
"restore_session": True,
15+
"last_project_path": "",
16+
"last_document_id": "",
17+
"auto_save_enabled": True,
1418
"auto_save_interval": 30, # 秒
1519
"backup_count": 5,
1620
"check_updates": True,

src/gui/editor/text_editor.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -785,8 +785,12 @@ def _on_text_changed(self):
785785
self.clear_ghost_text()
786786

787787
# 重启自动保存定时器
788-
auto_save_interval = self._config.get("app", "auto_save_interval", 30) * 1000
789-
self._auto_save_timer.start(auto_save_interval)
788+
auto_save_enabled = bool(self._config.get("app", "auto_save_enabled", True))
789+
if auto_save_enabled:
790+
auto_save_interval = self._config.get("app", "auto_save_interval", 30) * 1000
791+
self._auto_save_timer.start(auto_save_interval)
792+
else:
793+
self._auto_save_timer.stop()
790794

791795
# 发出文本变化信号
792796
text = self.toPlainText()
@@ -844,6 +848,8 @@ def _on_cursor_position_changed(self):
844848
@pyqtSlot()
845849
def _trigger_auto_save(self):
846850
"""触发自动保存"""
851+
if not bool(self._config.get("app", "auto_save_enabled", True)):
852+
return
847853
if self._is_modified:
848854
content = self.toPlainText()
849855
if content != self._last_save_content:

src/gui/main_window_parts/dialogs.py

Lines changed: 100 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,107 @@
4747
class MainWindowDialogsMixin:
4848
"""MainWindow mixin."""
4949
def _show_preferences(self):
50-
dialog = SettingsDialog(self, {})
50+
dialog = SettingsDialog(self, self._get_settings_dialog_payload())
51+
dialog.settingsChanged.connect(self._apply_settings_dialog_payload)
5152
dialog.exec()
53+
54+
def _get_settings_dialog_payload(self) -> dict:
55+
def _map_theme_to_label(theme: str) -> str:
56+
mapping = {
57+
"dark": "深色主题",
58+
"light": "浅色主题",
59+
"high_contrast": "高对比度",
60+
}
61+
return mapping.get(theme, "深色主题")
62+
63+
def _map_language_to_label(language: str) -> str:
64+
mapping = {
65+
"zh_CN": "简体中文",
66+
"en_US": "English",
67+
"zh_TW": "繁體中文",
68+
}
69+
return mapping.get(language, "简体中文")
70+
71+
try:
72+
auto_save_interval_seconds = int(self._config.get("app", "auto_save_interval", 30))
73+
except Exception:
74+
auto_save_interval_seconds = 30
75+
auto_save_minutes = max(1, min(60, max(1, auto_save_interval_seconds) // 60))
76+
77+
return {
78+
"general": {
79+
"language": _map_language_to_label(str(self._config.get("app", "language", "zh_CN"))),
80+
"theme": _map_theme_to_label(str(self._config.get("ui", "theme", "dark"))),
81+
"auto_save": bool(self._config.get("app", "auto_save_enabled", True)),
82+
"auto_save_interval": auto_save_minutes,
83+
"backup_enabled": bool(self._config.get("project", "auto_backup", True)),
84+
"backup_count": int(self._config.get("app", "backup_count", 5)),
85+
"restore_session": bool(self._config.get("app", "restore_session", True)),
86+
},
87+
"editor": dict(self._config.get_section("editor") or {}),
88+
"ai": {},
89+
}
90+
91+
@pyqtSlot(dict)
92+
def _apply_settings_dialog_payload(self, settings: dict) -> None:
93+
def _map_label_to_theme(theme_label: str) -> str:
94+
mapping = {
95+
"深色主题": "dark",
96+
"浅色主题": "light",
97+
"高对比度": "high_contrast",
98+
}
99+
return mapping.get(theme_label, "dark")
100+
101+
def _map_label_to_language(language_label: str) -> str:
102+
mapping = {
103+
"简体中文": "zh_CN",
104+
"English": "en_US",
105+
"繁體中文": "zh_TW",
106+
}
107+
return mapping.get(language_label, "zh_CN")
108+
109+
general = settings.get("general", {}) if isinstance(settings, dict) else {}
110+
editor_settings = settings.get("editor", {}) if isinstance(settings, dict) else {}
111+
112+
try:
113+
with self._config.batch_update():
114+
self._config.set("app", "language", _map_label_to_language(str(general.get("language", "简体中文"))))
115+
self._config.set("ui", "theme", _map_label_to_theme(str(general.get("theme", "深色主题"))))
116+
117+
self._config.set("app", "restore_session", bool(general.get("restore_session", True)))
118+
self._config.set("app", "auto_save_enabled", bool(general.get("auto_save", True)))
119+
120+
try:
121+
minutes = int(general.get("auto_save_interval", 5))
122+
except Exception:
123+
minutes = 5
124+
minutes = max(1, min(60, minutes))
125+
self._config.set("app", "auto_save_interval", minutes * 60)
126+
127+
self._config.set("project", "auto_backup", bool(general.get("backup_enabled", True)))
128+
try:
129+
backup_count = int(general.get("backup_count", 5))
130+
except Exception:
131+
backup_count = 5
132+
self._config.set("app", "backup_count", max(1, min(20, backup_count)))
133+
134+
if isinstance(editor_settings, dict) and editor_settings:
135+
merged_editor = dict(self._config.get_section("editor") or {})
136+
merged_editor.update(editor_settings)
137+
self._config.set_section("editor", merged_editor)
138+
except Exception:
139+
logger.exception("Failed to apply settings dialog payload")
140+
141+
try:
142+
self._shared.auto_save_enabled = bool(self._config.get("app", "auto_save_enabled", True))
143+
except Exception:
144+
pass
145+
146+
if hasattr(self, "_apply_theme"):
147+
try:
148+
self._apply_theme()
149+
except Exception:
150+
logger.debug("Theme apply failed", exc_info=True)
52151
def _show_about(self):
53152
dialog = AboutDialog(self)
54153
dialog.exec()

src/gui/main_window_parts/integrations.py

Lines changed: 59 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ class MainWindowIntegrationsMixin:
4848
"""MainWindow mixin."""
4949
def _init_signals(self):
5050
self._shared.projectChanged.connect(self._on_project_changed)
51+
self._shared.documentChanged.connect(self._on_document_changed_for_session_restore)
5152
self._shared.themeChanged.connect(self._on_theme_changed)
5253
self._theme_manager.themeChanged.connect(self._on_theme_manager_changed)
5354

@@ -411,14 +412,69 @@ def _on_project_opened(self, project_path: str):
411412
logger.warning("项目打开后AI客户端不可用,尝试恢复")
412413
self._ai_manager.force_reinit_ai()
413414

414-
# Open a default project document to avoid leaving users in an unbound scratch tab.
415+
# Persist session metadata and open the last document when appropriate.
415416
try:
416-
self._open_default_document_after_project_open()
417+
self._open_last_or_default_document_after_project_open(project_path)
417418
except Exception as exc: # noqa: BLE001
418-
logger.debug("Failed to open default document after project open: %s", exc)
419+
logger.debug("Failed to open session/default document after project open: %s", exc)
419420

420421
logger.info(f"Project opened at: {project_path}")
421422

423+
@pyqtSlot(str)
424+
def _on_document_changed_for_session_restore(self, document_id: str) -> None:
425+
"""Persist last active project/document for optional session restore."""
426+
try:
427+
if not document_id or document_id == "default_doc":
428+
return
429+
if not getattr(self, "_project_manager", None) or not self._project_manager.has_project():
430+
return
431+
if not self._project_manager.get_document(document_id):
432+
return
433+
434+
self._config.set("app", "last_document_id", document_id)
435+
436+
project_path = ""
437+
try:
438+
current_path = self._shared.current_project_path
439+
if current_path:
440+
project_path = str(current_path)
441+
except Exception:
442+
project_path = ""
443+
444+
if project_path:
445+
self._config.set("app", "last_project_path", project_path)
446+
except Exception:
447+
logger.debug("Failed to persist session restore metadata", exc_info=True)
448+
449+
def _open_last_or_default_document_after_project_open(self, project_path: str) -> None:
450+
"""Open last active doc for the same project; fallback to default scene."""
451+
prior_last_project = str(self._config.get("app", "last_project_path", "") or "").strip()
452+
last_document_id = str(self._config.get("app", "last_document_id", "") or "").strip()
453+
restore_enabled = bool(self._config.get("app", "restore_session", True))
454+
455+
try:
456+
self._config.set("app", "last_project_path", project_path)
457+
except Exception:
458+
logger.debug("Failed to persist last_project_path", exc_info=True)
459+
460+
def _norm(path_str: str) -> str:
461+
from pathlib import Path
462+
463+
if not path_str:
464+
return ""
465+
try:
466+
return str(Path(path_str).resolve())
467+
except Exception:
468+
return str(Path(path_str))
469+
470+
if restore_enabled and prior_last_project and last_document_id:
471+
if _norm(prior_last_project) == _norm(project_path):
472+
if getattr(self, "_project_manager", None) and self._project_manager.get_document(last_document_id):
473+
self._on_document_selected(last_document_id)
474+
return
475+
476+
self._open_default_document_after_project_open()
477+
422478
def _open_default_document_after_project_open(self) -> None:
423479
"""Open a default project document if the editor is still on an empty scratch tab."""
424480
project_manager = getattr(self, "_project_manager", None)

src/main.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,46 @@ def main():
249249
logger.info("AI Novel Editor started successfully")
250250

251251
smoke_project_dir = os.environ.get("ANE_SMOKE_PROJECT", "").strip()
252+
253+
def _restore_last_session() -> None:
254+
try:
255+
if not bool(config_instance.get("app", "restore_session", True)):
256+
return
257+
258+
recent_projects = config_instance.get("project", "recent_projects", [])
259+
project_path = str(config_instance.get("app", "last_project_path", "") or "").strip()
260+
if (not project_path) and isinstance(recent_projects, list) and recent_projects:
261+
project_path = str(recent_projects[0] or "").strip()
262+
263+
if not project_path:
264+
return
265+
266+
db_file = Path(project_path) / "project.db"
267+
if not db_file.is_file():
268+
logger.info("Session restore skipped; project.db not found: %s", db_file)
269+
return
270+
271+
ok = bool(project_manager_instance.open_project(project_path))
272+
logger.info("Session restore open_project ok=%s path=%s", ok, project_path)
273+
if not ok:
274+
return
275+
276+
try:
277+
main_window._project_controller.project_opened.emit(project_path)
278+
main_window._project_controller.project_structure_changed.emit()
279+
except Exception:
280+
logger.debug("Session restore controller signal emit failed", exc_info=True)
281+
try:
282+
main_window._on_project_opened(project_path)
283+
main_window._on_project_structure_changed()
284+
except Exception:
285+
logger.debug("Session restore UI refresh failed", exc_info=True)
286+
except Exception:
287+
logger.exception("Session restore failed")
288+
289+
if not smoke_project_dir:
290+
QTimer.singleShot(0, _restore_last_session)
291+
252292
if smoke_project_dir:
253293
logger.warning("SMOKE mode enabled via ANE_SMOKE_PROJECT=%s", smoke_project_dir)
254294

0 commit comments

Comments
 (0)