diff --git a/README.md b/README.md index bb82e23e..d3622227 100644 --- a/README.md +++ b/README.md @@ -245,6 +245,10 @@ directory browser, you pick the project, and a tmux window with the selected agent starts there. Subsequent text in the DM is routed to the **active** session. +Directory rows are ordered by the newest meaningful file change anywhere in +their nested contents. Generated dependency/cache trees are ignored, and the +scan runs off the Telegram event loop with a short cache. + Sessions are named after the directory basename and renamed once after the first message of ≥ 20 chars by a small separate request: Haiku for Claude or `CODEX_NAMING_MODEL` for Codex (default `gpt-5.6-luna`). The diff --git a/README_CN.md b/README_CN.md index 2cc274bf..3dfcd067 100644 --- a/README_CN.md +++ b/README_CN.md @@ -213,6 +213,9 @@ transcript 表面触手可及。多数用户一旦发现菜单,就再也不打 s 你选择项目,tmux 窗口中启动当前选定的 agent。后续 DM 中的文本路由到**活动** 会话。 +目录按嵌套内容中最新的有效文件修改排序。依赖和缓存目录会被忽略, +扫描在 Telegram 事件循环之外运行,并进行短时缓存。 + 会话最初以目录名命名,在第一条 ≥ 20 字符的消息到达时由一次性 小型独立调用重命名一次:Claude 使用 Haiku,Codex 使用 `CODEX_NAMING_MODEL`。关闭自动命名即可保留目录名并跳过额外调用。 diff --git a/README_RU.md b/README_RU.md index 9d390e58..30573b94 100644 --- a/README_RU.md +++ b/README_RU.md @@ -243,6 +243,10 @@ cross-agent pickup: ccbot парсит исходный JSONL в огранич браузер директорий, ты выберешь проект, в tmux-окне стартанёт выбранный агент. Дальнейший текст в DM роутится в **активную** сессию. +Папки сортируются по самому свежему содержательному изменению файлов во +вложенном дереве. Деревья зависимостей и кэшей игнорируются, обход выполняется +вне Telegram event loop и кратко кэшируется. + Имя сессии сначала берётся из имени каталога, а на первом сообщении длиной ≥ 20 символов один раз переписывается коротким отдельным запросом: Haiku для Claude или `CODEX_NAMING_MODEL` для Codex (по diff --git a/src/ccbot/bot/callbacks/dir_browser.py b/src/ccbot/bot/callbacks/dir_browser.py index c85a0e20..cc3238b3 100644 --- a/src/ccbot/bot/callbacks/dir_browser.py +++ b/src/ccbot/bot/callbacks/dir_browser.py @@ -117,7 +117,7 @@ async def handle( context.user_data[BROWSE_PATH_KEY] = new_path_str context.user_data[BROWSE_PAGE_KEY] = 0 - msg_text, keyboard, subdirs = build_directory_browser( + msg_text, keyboard, subdirs = await build_directory_browser( new_path_str, user_id=user.id ) if context.user_data is not None: @@ -138,7 +138,7 @@ async def handle( context.user_data[BROWSE_PATH_KEY] = parent_path context.user_data[BROWSE_PAGE_KEY] = 0 - msg_text, keyboard, subdirs = build_directory_browser( + msg_text, keyboard, subdirs = await build_directory_browser( parent_path, user_id=user.id ) if context.user_data is not None: @@ -162,7 +162,7 @@ async def handle( if context.user_data is not None: context.user_data[BROWSE_PAGE_KEY] = pg - msg_text, keyboard, subdirs = build_directory_browser( + msg_text, keyboard, subdirs = await build_directory_browser( current_path, pg, user_id=user.id ) if context.user_data is not None: @@ -187,7 +187,7 @@ async def handle( "Directory selection expired — pick again", show_alert=True ) start_path = str(Path.home()) - msg_text, keyboard, subdirs = build_directory_browser( + msg_text, keyboard, subdirs = await build_directory_browser( start_path, user_id=user.id ) if context.user_data is not None: @@ -292,7 +292,7 @@ async def handle( context.user_data.pop("_selected_path", None) context.user_data.pop(SESSIONS_PAGE_KEY, None) - msg_text, keyboard, subdirs = build_directory_browser( + msg_text, keyboard, subdirs = await build_directory_browser( selected_path, user_id=user.id ) if context.user_data is not None: diff --git a/src/ccbot/bot/callbacks/more_menu.py b/src/ccbot/bot/callbacks/more_menu.py index 3dea696f..85d038bc 100644 --- a/src/ccbot/bot/callbacks/more_menu.py +++ b/src/ccbot/bot/callbacks/more_menu.py @@ -62,7 +62,9 @@ async def _emit_new_flow( clear_window_picker_state(context.user_data) clear_session_picker_state(context.user_data) start_path = str(Path.home()) - msg_text, keyboard, subdirs = build_directory_browser(start_path, user_id=user.id) + msg_text, keyboard, subdirs = await build_directory_browser( + start_path, user_id=user.id + ) if context.user_data is not None: context.user_data[STATE_KEY] = STATE_BROWSING_DIRECTORY context.user_data[BROWSE_PATH_KEY] = start_path diff --git a/src/ccbot/bot/callbacks/switcher.py b/src/ccbot/bot/callbacks/switcher.py index 350d9ce7..b1cf13b7 100644 --- a/src/ccbot/bot/callbacks/switcher.py +++ b/src/ccbot/bot/callbacks/switcher.py @@ -246,7 +246,7 @@ async def _seed_bg_status(old_sess: _Session) -> None: clear_window_picker_state(context.user_data) clear_session_picker_state(context.user_data) start_path = str(Path.home()) - msg_text, keyboard, subdirs = build_directory_browser( + msg_text, keyboard, subdirs = await build_directory_browser( start_path, user_id=user.id ) if context.user_data is not None: diff --git a/src/ccbot/bot/callbacks/window_picker.py b/src/ccbot/bot/callbacks/window_picker.py index 41b996e6..07b8a574 100644 --- a/src/ccbot/bot/callbacks/window_picker.py +++ b/src/ccbot/bot/callbacks/window_picker.py @@ -96,7 +96,7 @@ async def handle( if data == CB_WIN_NEW: clear_window_picker_state(context.user_data) start_path = str(Path.home()) - msg_text, keyboard, subdirs = build_directory_browser( + msg_text, keyboard, subdirs = await build_directory_browser( start_path, user_id=user.id ) if context.user_data is not None: diff --git a/src/ccbot/bot/commands/lifecycle.py b/src/ccbot/bot/commands/lifecycle.py index bc6a3cbe..cf4b2a67 100644 --- a/src/ccbot/bot/commands/lifecycle.py +++ b/src/ccbot/bot/commands/lifecycle.py @@ -112,7 +112,9 @@ async def new_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> Non context.user_data["_pending_session_name"] = name_arg clear_browse_state(context.user_data) start_path = str(Path.home()) - msg_text, keyboard, subdirs = build_directory_browser(start_path, user_id=user.id) + msg_text, keyboard, subdirs = await build_directory_browser( + start_path, user_id=user.id + ) if context.user_data is not None: context.user_data[STATE_KEY] = STATE_BROWSING_DIRECTORY context.user_data[BROWSE_PATH_KEY] = start_path diff --git a/src/ccbot/bot/messages.py b/src/ccbot/bot/messages.py index 1c7d2bb8..641193c8 100644 --- a/src/ccbot/bot/messages.py +++ b/src/ccbot/bot/messages.py @@ -1174,7 +1174,7 @@ async def _resolve_active_window( # The pending text is held in user_data and forwarded after creation. logger.info("No active session: showing directory browser (user=%d)", user_id) start_path = str(Path.home()) - msg_text, keyboard, subdirs = build_directory_browser( + msg_text, keyboard, subdirs = await build_directory_browser( start_path, user_id=user_id ) if context.user_data is not None: diff --git a/src/ccbot/handlers/directory_browser.py b/src/ccbot/handlers/directory_browser.py index 65869253..a8126442 100644 --- a/src/ccbot/handlers/directory_browser.py +++ b/src/ccbot/handlers/directory_browser.py @@ -12,6 +12,7 @@ - clear_browse_state: Clear browsing state from user_data """ +import asyncio import logging import os import time @@ -39,32 +40,133 @@ ) -def _dir_recency(d: Path) -> float: - """Best-effort "last touched" timestamp for a directory. - - A directory's own mtime only changes when an entry is added, - removed, or renamed directly inside it — editing an existing file's - content, or ``git commit`` (which only touches objects/refs under - ``.git/``), leaves the project root's own mtime untouched. That - silently buried actively-worked-on git repos under stale scratch - dirs in the picker. Take the max of the directory's own mtime and - its ``.git/HEAD`` / ``.git/index`` mtimes (updated by nearly every - git operation: commit, checkout, add, merge, rebase, reset) without - doing a full recursive tree walk. +_RECENCY_CACHE_TTL_S = 30.0 +_RECENCY_MAX_DEPTH = 8 +_RECENCY_MAX_ENTRIES = 150_000 +_RECENCY_CACHE: dict[str, tuple[float, float]] = {} + +# Generated dependencies, caches and platform stores should not make a project +# look recently edited. Pruning them also keeps a home-directory scan bounded. +_RECENCY_PRUNE_DIRS = frozenset( + { + ".cache", + ".git", + ".hg", + ".mypy_cache", + ".next", + ".nox", + ".pytest_cache", + ".ruff_cache", + ".svn", + ".tox", + ".venv", + "Applications", + "DerivedData", + "Library", + "Pods", + "__pycache__", + "build", + "dist", + "node_modules", + "target", + "venv", + } +) + +_GIT_ACTIVITY_MARKERS = ( + "HEAD", + "index", + "packed-refs", + "logs/HEAD", + "FETCH_HEAD", +) + + +def _git_recency(git_dir: Path) -> float: + """Newest cheap Git activity marker without walking object storage.""" + best = 0.0 + for marker in _GIT_ACTIVITY_MARKERS: + try: + best = max(best, (git_dir / marker).stat().st_mtime) + except OSError: + continue + return best + + +def _scan_dir_recency(d: Path) -> float: + """Return the newest meaningful mtime in ``d``'s nested contents. + + Symlinks are never followed. Expensive generated trees are pruned, while + Git activity is represented by a handful of metadata markers. Depth and + entry caps protect the picker from pathological filesystem trees. """ try: best = d.stat().st_mtime except OSError: return 0.0 - git_dir = d / ".git" - for marker in ("HEAD", "index"): + if d.name in _RECENCY_PRUNE_DIRS: + return best + + stack: list[tuple[Path, int]] = [(d, 0)] + scanned = 0 + while stack and scanned < _RECENCY_MAX_ENTRIES: + current, depth = stack.pop() try: - best = max(best, (git_dir / marker).stat().st_mtime) + entries = os.scandir(current) except OSError: continue + with entries: + for entry in entries: + scanned += 1 + if scanned > _RECENCY_MAX_ENTRIES: + break + try: + is_dir = entry.is_dir(follow_symlinks=False) + except OSError: + continue + if is_dir and entry.name == ".git": + best = max(best, _git_recency(Path(entry.path))) + continue + if is_dir and entry.name in _RECENCY_PRUNE_DIRS: + continue + try: + stat = entry.stat(follow_symlinks=False) + except OSError: + continue + best = max(best, stat.st_mtime) + if not is_dir: + continue + if depth >= _RECENCY_MAX_DEPTH: + continue + stack.append((Path(entry.path), depth + 1)) return best +def _dir_recency(d: Path) -> float: + """Cached recursive content recency for one directory-picker row.""" + key = str(d) + now = time.monotonic() + cached = _RECENCY_CACHE.get(key) + if cached is not None and now - cached[0] < _RECENCY_CACHE_TTL_S: + return cached[1] + recency = _scan_dir_recency(d) + _RECENCY_CACHE[key] = (now, recency) + return recency + + +def _sorted_subdirs(path: Path) -> list[str]: + """List visible child directories, newest nested content first.""" + candidates: list[tuple[float, str]] = [] + for d in path.iterdir(): + if not d.is_dir(): + continue + if not config.show_hidden_dirs and d.name.startswith("."): + continue + candidates.append((_dir_recency(d), d.name)) + candidates.sort(key=lambda item: (-item[0], item[1].lower())) + return [name for _, name in candidates] + + # Directories per page in directory browser DIRS_PER_PAGE = 6 @@ -166,7 +268,7 @@ def clear_session_picker_state(user_data: dict[str, Any] | None) -> None: user_data.pop(SESSIONS_KEY, None) -def build_directory_browser( +async def build_directory_browser( current_path: str, page: int = 0, *, user_id: int ) -> tuple[str, InlineKeyboardMarkup, list[str]]: """Build directory browser UI. @@ -178,17 +280,9 @@ def build_directory_browser( path = Path.home() try: - # Sort by mtime descending — most recently changed directories first. - # Fall back to alphabetical for any directory whose stat fails. - candidates: list[tuple[float, str]] = [] - for d in path.iterdir(): - if not d.is_dir(): - continue - if not config.show_hidden_dirs and d.name.startswith("."): - continue - candidates.append((_dir_recency(d), d.name)) - candidates.sort(key=lambda t: (-t[0], t[1].lower())) - subdirs = [name for _, name in candidates] + # Recursive stats can be I/O-heavy, so never block Telegram's event + # loop while calculating the order. + subdirs = await asyncio.to_thread(_sorted_subdirs, path) except (PermissionError, OSError): subdirs = [] diff --git a/tests/test_directory_browser_sort.py b/tests/test_directory_browser_sort.py index 46555d54..7cafc69a 100644 --- a/tests/test_directory_browser_sort.py +++ b/tests/test_directory_browser_sort.py @@ -1,19 +1,13 @@ -"""Regression test for the session-creation directory picker's sort order. - -A directory's own mtime only changes when an entry is added, removed, or -renamed directly inside it — editing a tracked file's content, or a plain -``git commit`` (which only touches objects/refs under ``.git/``), never -touches it. Sorting by the raw ``st_mtime`` alone silently buried -actively-committed git repos under stale scratch directories in the -"most recent first" picker. ``_dir_recency`` additionally checks -``.git/HEAD`` / ``.git/index``. -""" +"""Session-creation directories sort by newest meaningful nested content.""" from __future__ import annotations import os import time +import pytest + +from ccbot.handlers import directory_browser from ccbot.handlers.directory_browser import build_directory_browser, _dir_recency @@ -22,6 +16,12 @@ def _touch(path: str, mtime: float) -> None: class TestDirRecency: + @pytest.fixture(autouse=True) + def clear_cache(self): + directory_browser._RECENCY_CACHE.clear() + yield + directory_browser._RECENCY_CACHE.clear() + def test_plain_directory_uses_own_mtime(self, tmp_path) -> None: d = tmp_path / "scratch" d.mkdir() @@ -54,9 +54,42 @@ def test_missing_git_dir_no_error(self, tmp_path) -> None: _touch(str(d), 2000.0) assert _dir_recency(d) == 2000.0 + def test_nested_file_mtime_wins(self, tmp_path) -> None: + project = tmp_path / "project" + nested = project / "src" / "package" + nested.mkdir(parents=True) + source = nested / "feature.py" + source.write_text("print('new')") + for path in (project, project / "src", nested): + _touch(str(path), 1000.0) + _touch(str(source), 7000.0) + + assert _dir_recency(project) == 7000.0 + + def test_generated_dependency_tree_does_not_win(self, tmp_path) -> None: + project = tmp_path / "project" + project.mkdir() + source = project / "app.py" + source.write_text("old") + dependency = project / ".venv" / "lib" / "package.py" + dependency.parent.mkdir(parents=True) + dependency.write_text("generated") + _touch(str(project), 1000.0) + _touch(str(source), 3000.0) + _touch(str(dependency), 9000.0) + + assert _dir_recency(project) == 3000.0 + class TestBuildDirectoryBrowserOrder: - def test_actively_committed_repo_sorts_above_stale_scratch_dir( + @pytest.fixture(autouse=True) + def clear_cache(self): + directory_browser._RECENCY_CACHE.clear() + yield + directory_browser._RECENCY_CACHE.clear() + + @pytest.mark.asyncio + async def test_actively_committed_repo_sorts_above_stale_scratch_dir( self, tmp_path, monkeypatch ) -> None: monkeypatch.setattr( @@ -79,5 +112,29 @@ def test_actively_committed_repo_sorts_above_stale_scratch_dir( scratch.mkdir() _touch(str(scratch), now - 86400) # touched 1 day ago - _, _, subdirs = build_directory_browser(str(tmp_path), user_id=1) + _, _, subdirs = await build_directory_browser(str(tmp_path), user_id=1) assert subdirs.index("aaa-old-repo") < subdirs.index("zzz-scratch") + + @pytest.mark.asyncio + async def test_nested_content_sorts_container_first( + self, tmp_path, monkeypatch + ) -> None: + monkeypatch.setattr( + "ccbot.handlers.directory_browser.config.show_hidden_dirs", False + ) + active = tmp_path / "aaa-container" + nested = active / "project" / "src" + nested.mkdir(parents=True) + changed = nested / "changed.py" + changed.write_text("latest") + stale = tmp_path / "zzz-directly-touched" + stale.mkdir() + + for path in (active, active / "project", nested): + _touch(str(path), 1000.0) + _touch(str(changed), 5000.0) + _touch(str(stale), 3000.0) + + _, _, subdirs = await build_directory_browser(str(tmp_path), user_id=1) + + assert subdirs.index("aaa-container") < subdirs.index("zzz-directly-touched")