↓快轉到主要內容
  1. 教學文章/

Textual DirectoryTree 檔案瀏覽器:安全路徑、非同步預覽與互動測試

·8 分鐘· loading · loading · ·
Python Textual DirectoryTree TUI Pathlib Asyncio Testing
每日拍拍
作者
每日拍拍
科學家 X 科技宅宅
目錄
Python 學習 - 本文屬於一個選集。
§ 135: 本文

featured

想在終端機裡挑檔案時,第一個直覺常常是列出 Path.iterdir(),再替每個項目畫按鈕。

嗯,可以動。但資料夾一深、檔案一多,就會開始出現展開狀態、鍵盤導覽、重新整理、權限錯誤與預覽卡頓等問題。拍拍君今天改用 Textual 內建的 DirectoryTree,做一個「只能待在指定根目錄」的文字檔瀏覽器。

這篇不再重講 Textual 的 Widget、CSS 基礎或一般測試入門;如果你還不熟,先看 Python Textual 實戰 與 Textual 測試實戰。今天只處理檔案樹真正麻煩的邊界。

一. 先定義產品邊界
#

我們要做的工具有五條明確規則:

  1. 左側只顯示指定工作區,不把整台機器攤開。
  2. 隱藏檔、快取目錄與不需要的項目不出現在樹上。
  3. 點選檔案時,必須重新確認路徑仍在安全根目錄內。
  4. 文字預覽有大小上限,讀檔不能凍結 UI。
  5. 快速切換檔案時,舊結果不能蓋掉新選擇。

這不是完整的檔案管理器:不提供刪除、搬移或執行檔案。先把唯讀瀏覽做好,比急著加危險操作可靠得多。

二. 安裝與專案結構
#

用 uv 建立一個小專案:

uv init textual-file-browser
cd textual-file-browser
uv add "textual>=8,<9"
uv add --dev pytest pytest-asyncio

本文以 Textual 8.2.8 驗證。若未來升級 major version,先重跑測試,不要只看畫面能不能啟動。

專案只需要 browser.py 與 tests/test_browser.py。DirectoryTree 已經處理展開、收合、游標與鍵盤操作,而且是在節點展開時載入內容,不會一開始就遞迴掃完整棵樹。

三. 用 filter_paths() 控制畫面噪音
#

.git、.venv、__pycache__ 通常不需要顯示。繼承 DirectoryTree,覆寫公開的 filter_paths(),在每個資料夾載入時排除名稱即可,不需要自己管理節點。完整實作稍後一起看。

但要注意:filter 是顯示規則,不是安全邊界。看不到某個路徑,不代表事件、symlink 或未來新增的功能絕對碰不到它。

四. 路徑安全:字串前綴不夠
#

假設允許根目錄是 /workspace/demo,用 str(candidate).startswith(str(root)) 並不安全:/workspace/demo-secret 也有相同字串前綴,.. 與 symbolic link 更會讓畫面路徑和真正目的地不同。

可靠做法是讓 root 與 candidate 都執行 resolve(strict=True),再用 resolved.is_relative_to(safe_root) 檢查;完整函式包含在後面的 browser.py。

resolve(strict=True) 有三個好處:

  • 正規化 . 與 ..。
  • 解析 symlink 的實際目的地。
  • 不存在的檔案直接失敗,不把模糊狀態往後傳。

例如根目錄裡有一個 outside -> /private/data 的 symlink,選到 outside/secret.txt 時,解析結果會落在根目錄外,因而被拒絕。

這個檢查適合本機唯讀工具,但它不是作業系統 sandbox。若檔案樹會面對惡意、多使用者同時修改的目錄,檢查與開檔之間仍可能有 TOCTOU race;那時應改用平台提供的 descriptor-relative API、容器或權限隔離。

五. 預覽函式:限制類型與讀取量
#

不要對任何檔案直接 read_text()。大型 log 會吃掉記憶體,binary 則可能把終端機弄得一團亂。

我們會把預覽寫成與 UI 無關的 read_preview():重新驗證路徑,只允許指定文字副檔名,而且最多讀取 64 KiB 加一個判斷截斷用的 byte。

先檢查副檔名只是降低誤讀機率,真正的第二道防線是 NUL byte 檢查。errors="replace" 則讓少量壞掉的 UTF-8 不會炸掉整個 UI。

如果你需要語法高亮,可再把內容交給 Rich Syntax;安全邊界與讀取上限仍應留在這個純函式裡。

六. 用 Selection Message 串起檔案樹
#

DirectoryTree 選到檔案時會送出 FileSelected,選到目錄則送出 DirectorySelected;父層 App 只要依命名慣例實作 on_directory_tree_file_selected() 與 on_directory_tree_directory_selected()。

事件帶來的 event.path 只能當輸入,不能當授權結果。真正讀檔前仍要呼叫 resolve_under_root()。

這正是 Textual 常說的單向資料流:父層把 root 傳給 Tree,子 Widget 再用 message 把使用者選擇往上送。

七. 非同步預覽,不要讓 UI 卡住
#

檔案 I/O 通常很快,但網路磁碟、冷 cache 或權限問題都可能拖住 event loop。我們用 asyncio.to_thread() 把同步讀取移到 worker thread,再用 Textual 的 @work(exclusive=True) 管生命週期。

exclusive=True 會取消同一 worker group 裡上一個仍在等待的工作。不過 to_thread() 裡已經開始的同步讀取不一定能立刻停止,所以還要加 generation token。

使用者先選 A、立刻再選 B 時:

  1. B 讓 generation 加一。
  2. A 即使晚回來,也發現 token 已過期。
  3. 畫面只接受 B 的結果。

這比假設「工作完成順序一定等於點擊順序」穩健得多。想深入 worker 取消與進度,可接著看 Textual Background Workers。

八. 完整可執行版本
#

把前面的元件收進 browser.py:

from __future__ import annotations

import asyncio
import sys
from collections.abc import Iterable
from pathlib import Path

from textual import work
from textual.app import App, ComposeResult
from textual.containers import Horizontal, Vertical
from textual.widgets import DirectoryTree, Footer, Header, Static


IGNORED_NAMES = {".git", ".venv", "__pycache__", ".pytest_cache"}
MAX_PREVIEW_BYTES = 64 * 1024
TEXT_SUFFIXES = {
    ".py", ".md", ".txt", ".toml", ".yaml", ".yml", ".json", ".csv"
}


def resolve_under_root(candidate: Path, root: Path) -> Path:
    safe_root = root.expanduser().resolve(strict=True)
    resolved = candidate.expanduser().resolve(strict=True)
    if not resolved.is_relative_to(safe_root):
        raise PermissionError(f"路徑超出允許範圍:{resolved}")
    return resolved


def read_preview(path: Path, root: Path) -> str:
    safe_path = resolve_under_root(path, root)
    if not safe_path.is_file():
        raise ValueError("選取項目不是一般檔案")
    if safe_path.suffix.lower() not in TEXT_SUFFIXES:
        return "此類型不提供文字預覽。"

    with safe_path.open("rb") as file:
        data = file.read(MAX_PREVIEW_BYTES + 1)

    truncated = len(data) > MAX_PREVIEW_BYTES
    data = data[:MAX_PREVIEW_BYTES]
    if b"\x00" in data:
        return "偵測到 binary 內容,不顯示預覽。"

    text = data.decode("utf-8", errors="replace")
    if truncated:
        text += "\n\n… 預覽已截斷 …"
    return text


class SafeDirectoryTree(DirectoryTree):
    def filter_paths(self, paths: Iterable[Path]) -> Iterable[Path]:
        return [
            path
            for path in paths
            if path.name not in IGNORED_NAMES
            and not path.name.startswith(".")
        ]


class FileBrowser(App[None]):
    TITLE = "拍拍君的安全檔案瀏覽器"
    BINDINGS = [("r", "reload_tree", "重新整理")]

    CSS = """
    Horizontal { height: 1fr; }
    #sidebar { width: 38%; min-width: 28; border-right: solid $primary; }
    #meta { height: 3; padding: 0 1; color: $text-muted; }
    #preview { height: 1fr; padding: 1 2; overflow: auto; }
    """

    def __init__(self, root: Path) -> None:
        super().__init__()
        self.root_path = root.expanduser().resolve(strict=True)
        self.preview_generation = 0

    def compose(self) -> ComposeResult:
        yield Header()
        with Horizontal():
            yield SafeDirectoryTree(self.root_path, id="sidebar")
            with Vertical():
                yield Static(f"根目錄:{self.root_path}", id="meta", markup=False)
                yield Static("請選擇文字檔。", id="preview", markup=False)
        yield Footer()

    def on_directory_tree_directory_selected(
        self,
        event: DirectoryTree.DirectorySelected,
    ) -> None:
        try:
            safe_path = resolve_under_root(event.path, self.root_path)
        except (OSError, PermissionError) as error:
            self.query_one("#meta", Static).update(f"拒絕:{error}")
            return
        self.query_one("#meta", Static).update(f"資料夾:{safe_path}")

    def on_directory_tree_file_selected(
        self,
        event: DirectoryTree.FileSelected,
    ) -> None:
        try:
            safe_path = resolve_under_root(event.path, self.root_path)
        except (OSError, PermissionError) as error:
            self.query_one("#meta", Static).update(f"拒絕:{error}")
            return

        self.preview_generation += 1
        self.query_one("#meta", Static).update(f"檔案:{safe_path}")
        self.load_preview(safe_path, self.preview_generation)

    @work(exclusive=True)
    async def load_preview(self, path: Path, generation: int) -> None:
        try:
            content = await asyncio.to_thread(read_preview, path, self.root_path)
        except (OSError, PermissionError, ValueError) as error:
            content = f"無法預覽:{error}"

        if generation == self.preview_generation:
            self.query_one("#preview", Static).update(content)

    async def action_reload_tree(self) -> None:
        tree = self.query_one("#sidebar", SafeDirectoryTree)
        await tree.reload()
        self.notify("檔案樹已重新整理")


if __name__ == "__main__":
    chosen_root = Path(sys.argv[1]) if len(sys.argv) > 1 else Path.cwd()
    FileBrowser(chosen_root).run()

啟動時可指定允許根目錄:

uv run python browser.py ~/code/my-project

預覽區使用 markup=False,因為檔案內容可能含有 Rich markup。否則像 [red] 這類文字會被當成樣式標記,而不是原始內容。

九. reload() 也要等待穩定狀態
#

檔案系統會在 App 開著時改變,所以綁定 r 重新整理。DirectoryTree.reload() 回傳可等待的 AwaitComplete:

async def action_reload_tree(self) -> None:
    tree = self.query_one("#sidebar", SafeDirectoryTree)
    await tree.reload()

await 的意義不是語法裝飾,而是保證後續邏輯看到重新載入後的穩定樹狀態。測試若要在 reload 後檢查新檔案,更不能省略。

若只改了某個已知資料夾,可用 reload_node(node) 縮小工作範圍;先保留簡單的全樹 reload,除非量測證明它真的慢。

十. 測試安全函式與真實訊息流程
#

第一組測試不啟動 UI,直接驗證邊界:

from pathlib import Path

import pytest

from browser import read_preview, resolve_under_root


def test_file_inside_root_is_allowed(tmp_path: Path) -> None:
    note = tmp_path / "note.md"
    note.write_text("hello", encoding="utf-8")
    assert resolve_under_root(note, tmp_path) == note.resolve()


def test_symlink_escape_is_rejected(tmp_path: Path) -> None:
    root = tmp_path / "root"
    outside = tmp_path / "outside"
    root.mkdir()
    outside.mkdir()
    secret = outside / "secret.txt"
    secret.write_text("nope", encoding="utf-8")

    link = root / "escape.txt"
    try:
        link.symlink_to(secret)
    except OSError:
        pytest.skip("此平台不允許建立 symlink")

    with pytest.raises(PermissionError):
        resolve_under_root(link, root)


def test_preview_is_bounded(tmp_path: Path) -> None:
    large = tmp_path / "large.txt"
    large.write_text("x" * (64 * 1024 + 10), encoding="utf-8")
    assert read_preview(large, tmp_path).endswith("… 預覽已截斷 …")

第二組測試讓 App 真的跑在 Textual test mode,並投遞 FileSelected message:

from pathlib import Path

import pytest
from textual.widgets import DirectoryTree, Static

from browser import FileBrowser, SafeDirectoryTree


@pytest.mark.asyncio
async def test_file_selection_updates_preview(tmp_path: Path) -> None:
    note = tmp_path / "note.md"
    note.write_text("拍拍君看到我了", encoding="utf-8")
    app = FileBrowser(tmp_path)

    async with app.run_test() as pilot:
        tree = app.query_one("#sidebar", SafeDirectoryTree)
        tree.post_message(DirectoryTree.FileSelected(tree.root, note))
        await pilot.pause()
        await pilot.pause()

        preview = app.query_one("#preview", Static)
        assert "拍拍君看到我了" in str(preview.content)


@pytest.mark.asyncio
async def test_reload_finishes_cleanly(tmp_path: Path) -> None:
    app = FileBrowser(tmp_path)

    async with app.run_test() as pilot:
        await app.action_reload_tree()
        await pilot.pause()
        assert app.query_one("#sidebar", SafeDirectoryTree).root is not None

執行:

uv run pytest -q

這裡刻意同時測三個層次:

  • 純路徑函式:快、錯誤定位清楚。
  • 真實 message:確認 event handler 與預覽 worker 接得起來。
  • reload await:確認非同步操作會收斂,不留下競態。

不要把每一個測試都寫成完整鍵盤旅程。Pilot 很有價值,但核心安全規則仍應由小而直接的 unit test 守住。

十一. 常見踩雷
#

1. 把 filter_paths() 當存取控制
#

它只決定畫面出現什麼。任何實際讀檔、寫檔、刪除操作,都要重新驗證 resolved path。

2. 只檢查 ..
#

沒有 .. 的 symlink 一樣能跳出 root。檢查正規化後的目的地,不要只掃字串片段。

3. 在 event handler 直接讀整個檔案
#

本機小檔看不出問題,換到大型 log 或網路磁碟就會卡住。限制 byte 數,並把同步 I/O 移出 event loop。

4. 忽略快速切換造成的舊結果
#

取消 worker 不代表底層 thread 已瞬間消失。用 generation token 保證只有最新選擇能更新畫面。

5. 預覽內容開著 markup
#

使用者檔案不是你信任的 UI 字串。關掉 Rich markup,避免內容被錯誤解析或造成奇怪顯示。

6. 每次 reload 都重設整個 App
#

DirectoryTree.reload() 已提供明確生命週期。先用它,必要時再縮小成 reload_node(),不要自己重建所有 Widget。

十二. 還可以怎麼擴充?
#

安全唯讀版本穩定後,可以逐步加入:

  • 用 Rich Syntax 做可選的語法高亮。
  • 顯示檔案大小、修改時間與 MIME 推測。
  • 用 Input 篩選目前已載入節點。
  • 用 Watchdog 監控變更,再節流呼叫 reload。
  • 針對大檔提供 tail preview,而不是從開頭讀取。
  • 在預覽之外加明確的「開啟」命令,但仍保留 root 驗證。

若要加入背景監控,記得先定義 debounce、錯誤顯示與關閉流程;不要讓每個 filesystem event 都觸發全樹 reload。

結語
#

DirectoryTree 幫我們省掉畫樹、鍵盤導覽與延遲展開,但一個可信任的檔案瀏覽器仍需要自己定義邊界。

拍拍君建議記住這四件事:顯示過濾不等於授權、路徑要 resolve、預覽要 bounded、非同步結果要防過期。

把這些規則放在純函式和測試裡,TUI 才不會只是看起來可愛,而是真的能放心放進日常工作流。

延伸閱讀
#

Python 學習 - 本文屬於一個選集。
§ 135: 本文

相關文章

Textual Command Palette + Actions:快捷鍵、命令搜尋與可測試操作
·8 分鐘· loading · loading
Python Textual TUI Command-Palette Actions Key-Bindings Testing
Textual Form Wizard 實戰:多步驟表單、Validation 與狀態切換
·5 分鐘· loading · loading
Python Textual TUI Forms Validation Developer-Tools
Python contextvars 實戰:Async Context、結構化 Logging 與隔離測試
·8 分鐘· loading · loading
Python Contextvars Asyncio Logging Concurrency Testing Standard-Library
Textual 測試實戰:Pilot、pytest-asyncio 與 Snapshot Regression
·7 分鐘· loading · loading
Python Textual TUI Pytest Pytest-Asyncio Pilot Snapshot Testing
Textual + SQLite 實戰:做一個終端機資料管理小工具
·8 分鐘· loading · loading
Python Textual SQLite TUI Database Developer-Tools
Textual CSS Layout 實戰:Grid、Dock、Responsive 與 Theme
·7 分鐘· loading · loading
Python Textual TCSS TUI Layout Responsive-Design Theme