diff --git a/app.py b/app.py index de07c55..8d268c7 100644 --- a/app.py +++ b/app.py @@ -1074,7 +1074,14 @@ class MainWindow(QMainWindow): self.workspace.refresh() # re-list projects + threads on entry widget = self._page_widgets[page] if sub is not None and hasattr(widget, "select_subtab"): - widget.select_subtab(sub) + # Enforce the project gate here rather than at each entry point. A + # greyed rail row cannot be clicked, but _goto is also reached from + # RECENTS and from startup restore, and it used to open a sub-tab + # the gate was holding shut — page shown, tab strip still hiding it. + if hasattr(widget, "subtab_available") and not widget.subtab_available(sub): + self.statusBar().showMessage(tr("app.nav.needs_project"), 4000) + else: + widget.select_subtab(sub) # Move the highlight with the content, however navigation was triggered — # a programmatic _goto used to leave it on whatever was clicked last. if not self._nav_building: diff --git a/tools/check_project_gate.py b/tools/check_project_gate.py new file mode 100644 index 0000000..07454dc --- /dev/null +++ b/tools/check_project_gate.py @@ -0,0 +1,116 @@ +"""With no project chosen, the gated screens must stay shut on every route. + +The redesign shows Cowork and GraphRAG at all times instead of making them +appear and disappear. That is a presentation change only — the gate is the same +`isTabVisible` state as before — so this asserts the gate still actually holds, +and holds for programmatic jumps too, not just for the greyed rail rows. + +Runs against an EMPTY home, not a copy of the real one: with any project on +disk the gate is open and the test proves nothing. +""" +from __future__ import annotations + +import os +import sys +import tempfile + +sys.stdout.reconfigure(encoding="utf-8", errors="replace") + +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(REPO.parent)) +sys.path.insert(0, str(Path(__file__).resolve().parent)) +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +sandbox = Path(tempfile.mkdtemp(prefix="cowork-gate-")) +(sandbox / ".cowork_local").mkdir(parents=True, exist_ok=True) +for _var in ("USERPROFILE", "HOME"): + os.environ[_var] = str(sandbox) +os.environ.pop("HOMEDRIVE", None) +os.environ.pop("HOMEPATH", None) + +from capture_screens import _apply_theme, _freeze_schedulers, _load_fonts # noqa: E402 + + +def main() -> int: + from PySide6.QtCore import Qt + from PySide6.QtWidgets import QApplication + + app = QApplication([]) + _load_fonts() + _freeze_schedulers() + _apply_theme(app) + + from cowork_local.config import CONFIG_DIR + assert str(sandbox) in str(CONFIG_DIR), f"isolation failed: {CONFIG_DIR}" + + from cowork_local.app import MainWindow + from cowork_local.config import AppConfig + from cowork_local.i18n import set_language + from cowork_local.state import AppContext + + set_language("vi") + win = MainWindow(AppContext(AppConfig.load()), user_name="local") + win.resize(1400, 900) + win.show() + app.processEvents() + + fails = [] + n_projects = len(win.workspace.project_choices()) + print(f"project tren dia: {n_projects}") + if n_projects: + print("FAIL home khong rong — phep thu vo nghia") + sys.stdout.flush() + os._exit(1) + + tree = win.nav + rows = [(i, tree.topLevelItem(i)) for i in range(tree.topLevelItemCount())] + locked = [(i, it) for i, it in rows if it.isDisabled()] + print("dong bi khoa :", [it.text(0) for _i, it in locked]) + print("dong mo binh thuong:", [it.text(0) for _i, it in rows if not it.isDisabled()]) + if not locked: + fails.append("khong project nao ma khong dong nao bi khoa") + + for _i, it in locked: + if not it.toolTip(0): + fails.append(f"dong khoa '{it.text(0)}' khong noi ly do") + + for i, it in locked: + label = it.text(0) + data = it.data(0, Qt.UserRole) or {} + before = win.workspace.current_subtab() + + tree.setCurrentItem(it) + app.processEvents() + if win.workspace.current_subtab() != before: + fails.append(f"bam duoc vao '{label}' du dang khoa") + + # the route a greyed row does not guard: a jump from code + win._goto(data.get("page", 0), data.get("sub")) + app.processEvents() + landed = win.workspace.current_subtab() + if landed == data.get("sub"): + fails.append(f"_goto mo duoc '{label}' trong khi cong dang dong") + print(f" '{label}': bam -> {before}, _goto -> {landed} " + f"(tab hien = {win.workspace.tabs.isTabVisible(data.get('sub'))})") + + # controls that would act on a project must be off too + for name, widget in (("+ chat moi", win.nav_new_chat), + ("chon project", win.nav_project_btn)): + if widget.isEnabled(): + fails.append(f"'{name}' van bam duoc khi chua co project") + print(f"+ chat moi bat={win.nav_new_chat.isEnabled()} " + f"tooltip={win.nav_new_chat.toolTip()!r}") + + print() + for f in fails: + print("FAIL " + f) + print("PASS cong project van giu, ca khi bam lan khi goi tu code" if not fails + else f"{len(fails)} problem(s)") + sys.stdout.flush() + os._exit(1 if fails else 0) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/ui/workspace_tab.py b/ui/workspace_tab.py index 3a26d96..99e9a1d 100644 --- a/ui/workspace_tab.py +++ b/ui/workspace_tab.py @@ -67,6 +67,14 @@ class WorkspaceTab(QWidget): self.tabs.isTabVisible(i)) for i in range(self.tabs.count())] + def subtab_available(self, index: int) -> bool: + """False while the project gate is holding this sub-tab shut. + + The rail greys those rows out, but that only guards the rail. This lets + every other route ask the same question of the same state. + """ + return bool(0 <= index < self.tabs.count() and self.tabs.isTabVisible(index)) + def select_subtab(self, index: int) -> None: if 0 <= index < self.tabs.count(): self.tabs.setCurrentIndex(index)