From 2b0614ce3573a2fa4a0722ac8a7b73716f4a88e8 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Tue, 14 Jul 2026 03:20:30 -0400 Subject: [PATCH] feat(#5932): add per-board default workdir --- api/kanban_bridge.py | 14 ++ static/i18n.js | 30 +++++ static/index.html | 5 + static/panels.js | 37 ++++-- ..._issue5932_kanban_board_default_workdir.py | 123 ++++++++++++++++++ ...932_kanban_board_default_workdir_layout.py | 59 +++++++++ 6 files changed, 254 insertions(+), 14 deletions(-) create mode 100644 tests/test_issue5932_kanban_board_default_workdir.py create mode 100644 tests/test_issue5932_kanban_board_default_workdir_layout.py diff --git a/api/kanban_bridge.py b/api/kanban_bridge.py index 0ba9ff32..bd8dfaa9 100644 --- a/api/kanban_bridge.py +++ b/api/kanban_bridge.py @@ -20,6 +20,7 @@ from dataclasses import asdict, is_dataclass from urllib.parse import parse_qs, unquote from api.helpers import bad, j +from api.workspace import resolve_trusted_workspace BOARD_COLUMNS = ["triage", "todo", "ready", "running", "blocked", "done"] _TASK_PREFIX = "/api/kanban/tasks/" @@ -855,6 +856,13 @@ def _create_board_payload(body): slug = str(body.get("slug") or "").strip() if not slug: raise ValueError("slug is required") + board_kwargs = {} + if "default_workdir" in body: + raw_workdir = str(body.get("default_workdir") or "").strip() + if raw_workdir: + board_kwargs["default_workdir"] = str(resolve_trusted_workspace(raw_workdir)) + else: + board_kwargs["default_workdir"] = "" try: meta = kb.create_board( slug, @@ -862,6 +870,7 @@ def _create_board_payload(body): description=body.get("description") or None, icon=body.get("icon") or None, color=body.get("color") or None, + **board_kwargs, ) except (ValueError, AttributeError) as exc: raise ValueError(str(exc)) from exc @@ -894,6 +903,10 @@ def _update_board_payload(slug, body): raise ValueError(f"invalid board slug: {slug!r}") from exc if not normed or not kb.board_exists(normed): raise LookupError(f"board {slug!r} does not exist") + board_kwargs = {} + if "default_workdir" in body: + raw_workdir = str(body.get("default_workdir") or "").strip() + board_kwargs["default_workdir"] = str(resolve_trusted_workspace(raw_workdir)) if raw_workdir else "" archived = body.get("archived") if isinstance(archived, str): archived = archived.strip().lower() in {"1", "true", "yes", "on"} @@ -904,6 +917,7 @@ def _update_board_payload(slug, body): icon=body.get("icon"), color=body.get("color"), archived=archived if isinstance(archived, bool) else None, + **board_kwargs, ) return {"board": _board_meta_dict(meta), "read_only": False} diff --git a/static/i18n.js b/static/i18n.js index cca91772..120e62f4 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -1052,6 +1052,8 @@ const LOCALES = { kanban_board_description: 'Description (optional)', kanban_board_icon: 'Icon (emoji, optional)', kanban_board_color: 'Color (optional)', + kanban_board_default_workdir: 'Default workdir', + kanban_board_default_workdir_placeholder: 'Workspace path (optional)', kanban_board_name_required: 'Name is required', kanban_board_slug_required: 'Slug is required', kanban_card_complete: 'complete', @@ -2814,6 +2816,8 @@ const LOCALES = { kanban_board_description: 'Descrizione (opzionale)', kanban_board_icon: 'Icona (emoji, opzionale)', kanban_board_color: 'Colore (opzionale)', + kanban_board_default_workdir: 'Workspace predefinito', + kanban_board_default_workdir_placeholder: 'Percorso workspace (opzionale)', kanban_board_name_required: 'Il nome è obbligatorio', kanban_board_slug_required: 'Lo slug è obbligatorio', kanban_card_complete: 'completato', @@ -4559,6 +4563,8 @@ const LOCALES = { kanban_board_description: '説明 (任意)', kanban_board_icon: 'アイコン (絵文字、任意)', kanban_board_color: '色 (任意)', + kanban_board_default_workdir: '既定の作業ディレクトリ', + kanban_board_default_workdir_placeholder: 'ワークスペースのパス(任意)', kanban_board_name_required: '名前は必須です', kanban_board_slug_required: 'スラッグは必須です', kanban_card_complete: '完了', @@ -5998,6 +6004,8 @@ const LOCALES = { kanban_board_description: 'Описание (необязательно)', kanban_board_icon: 'Иконка (emoji, необязательно)', kanban_board_color: 'Цвет (необязательно)', + kanban_board_default_workdir: 'Рабочая папка по умолчанию', + kanban_board_default_workdir_placeholder: 'Путь к workspace (необязательно)', kanban_board_name_required: 'Укажите имя', kanban_board_slug_required: 'Укажите slug', kanban_card_complete: 'завершить', @@ -7706,6 +7714,8 @@ const LOCALES = { kanban_board_description: 'Description (optional)', kanban_board_icon: 'Icon (emoji, optional)', kanban_board_color: 'Color (optional)', + kanban_board_default_workdir: 'Directorio de trabajo predeterminado', + kanban_board_default_workdir_placeholder: 'Ruta del workspace (opcional)', kanban_board_name_required: 'Name is required', kanban_board_slug_required: 'Slug is required', kanban_card_complete: 'complete', @@ -9373,6 +9383,8 @@ const LOCALES = { kanban_board_description: 'Description (optional)', kanban_board_icon: 'Icon (emoji, optional)', kanban_board_color: 'Color (optional)', + kanban_board_default_workdir: 'Standard-Arbeitsverzeichnis', + kanban_board_default_workdir_placeholder: 'Workspace-Pfad (optional)', kanban_board_name_required: 'Name is required', kanban_board_slug_required: 'Slug is required', kanban_card_complete: 'complete', @@ -11091,6 +11103,8 @@ const LOCALES = { kanban_board_description: '描述(可选)', kanban_board_icon: '图标(emoji,可选)', kanban_board_color: '颜色(可选)', + kanban_board_default_workdir: '默认工作目录', + kanban_board_default_workdir_placeholder: '工作区路径(可选)', kanban_board_name_required: '名称为必填', kanban_board_slug_required: '标识为必填', kanban_card_complete: '完成', @@ -13082,6 +13096,8 @@ const LOCALES = { kanban_board_description: '描述(選填)', kanban_board_icon: '圖示(emoji,選填)', kanban_board_color: '顏色(選填)', + kanban_board_default_workdir: '預設工作目錄', + kanban_board_default_workdir_placeholder: '工作區路徑(選填)', kanban_board_name_required: '名稱為必填', kanban_board_slug_required: 'Slug 為必填', kanban_card_complete: '完成', @@ -14713,6 +14729,8 @@ const LOCALES = { kanban_board_description: 'Description (optional)', kanban_board_icon: 'Icon (emoji, optional)', kanban_board_color: 'Color (optional)', + kanban_board_default_workdir: 'Diretório de trabalho padrão', + kanban_board_default_workdir_placeholder: 'Caminho do workspace (opcional)', kanban_board_name_required: 'Name is required', kanban_board_slug_required: 'Slug is required', kanban_card_complete: 'complete', @@ -16355,6 +16373,8 @@ const LOCALES = { kanban_board_description: 'Description (optional)', kanban_board_icon: 'Icon (emoji, optional)', kanban_board_color: 'Color (optional)', + kanban_board_default_workdir: '기본 작업 디렉터리', + kanban_board_default_workdir_placeholder: '워크스페이스 경로(선택 사항)', kanban_board_name_required: 'Name is required', kanban_board_slug_required: 'Slug is required', kanban_card_complete: 'complete', @@ -18120,6 +18140,8 @@ const LOCALES = { kanban_board_description: 'Description (facultatif)', kanban_board_icon: 'Icône (emoji, facultatif)', kanban_board_color: 'Couleur (facultatif)', + kanban_board_default_workdir: 'Répertoire par défaut', + kanban_board_default_workdir_placeholder: 'Chemin du workspace (facultatif)', kanban_board_name_required: 'Le nom est requis', kanban_board_slug_required: 'Le slug est requis', kanban_card_complete: 'terminer', @@ -19155,6 +19177,8 @@ const LOCALES = { kanban_board: 'Deska', kanban_board_archived: 'Deska archivována', kanban_board_color: 'Barva (volitelná)', + kanban_board_default_workdir: 'Výchozí pracovní adresář', + kanban_board_default_workdir_placeholder: 'Cesta workspace (volitelná)', kanban_board_description: 'Popis (volitelný)', kanban_board_icon: 'Ikona (emoji, volitelná)', kanban_board_name: 'Název', @@ -21530,6 +21554,8 @@ const LOCALES = { kanban_board_description: 'Açıklama (isteğe bağlı)', kanban_board_icon: 'Simge (emoji, isteğe bağlı)', kanban_board_color: 'Renk (isteğe bağlı)', + kanban_board_default_workdir: 'Varsayılan çalışma dizini', + kanban_board_default_workdir_placeholder: 'Workspace yolu (isteğe bağlı)', kanban_board_name_required: 'Ad gerekli', kanban_board_slug_required: 'Slug gerekli', kanban_card_complete: 'tamamlamak', @@ -23290,6 +23316,8 @@ const LOCALES = { kanban_board_description: 'Opis (opcjonalnie)', kanban_board_icon: 'Ikona (emoji, opcjonalnie)', kanban_board_color: 'Kolor (opcjonalnie)', + kanban_board_default_workdir: 'Domyślny katalog roboczy', + kanban_board_default_workdir_placeholder: 'Ścieżka workspace (opcjonalna)', kanban_board_name_required: 'Nazwa jest wymagana', kanban_board_slug_required: 'Identyfikator (slug) jest wymagany', kanban_card_complete: 'ukończone', @@ -24871,6 +24899,8 @@ const LOCALES = { kanban_board_description: 'Mô tả (tùy chọn)', kanban_board_icon: 'Biểu tượng (emoji, tùy chọn)', kanban_board_color: 'Màu sắc (tùy chọn)', + kanban_board_default_workdir: 'Workspace mặc định', + kanban_board_default_workdir_placeholder: 'Đường dẫn workspace (tùy chọn)', kanban_board_name_required: 'Tên là bắt buộc', kanban_board_slug_required: 'Slug là bắt buộc', kanban_card_complete: 'hoàn tất', diff --git a/static/index.html b/static/index.html index 8e9568de..8912f0dd 100644 --- a/static/index.html +++ b/static/index.html @@ -1795,6 +1795,11 @@ +
+ + + +
diff --git a/static/panels.js b/static/panels.js index 8ab5cc14..880ce1e0 100644 --- a/static/panels.js +++ b/static/panels.js @@ -3898,12 +3898,8 @@ async function loadKanbanBoards(){ _kanbanSetSavedBoard('default'); } _kanbanCurrentBoard = (active === 'default') ? null : active; - // The switcher is visible whenever ≥1 non-default board exists OR the - // current board is non-default. (If you only have 'default', a switcher - // adds clutter without value.) - const hasMultiple = boards.length > 1 || (active !== 'default'); - switcher.hidden = !hasMultiple; - if (!hasMultiple) return; + // Keep the switcher visible because it is also the default-board settings path. + switcher.hidden = false; // Update the toggle label/icon const activeMeta = boards.find(b => b.slug === active) || {slug: active, name: active, icon: '', color: ''}; const nameEl = document.getElementById('kanbanBoardSwitcherName'); @@ -3947,10 +3943,7 @@ function _renderKanbanBoardMenu(boards, current){ ${esc(String(total))} `; }).join(''); - // Actions row — disable rename/archive when the only option is `default` - // (the default board's display metadata is editable but the slug isn't, - // and `default` cannot be archived). - const renameDisabled = current === 'default'; + // The default board is editable but cannot be archived. const archiveDisabled = current === 'default'; const actions = ` @@ -3958,7 +3951,7 @@ function _renderKanbanBoardMenu(boards, current){ ${esc(t('kanban_new_board') || 'New board…')} - @@ -4035,6 +4028,16 @@ async function switchKanbanBoard(slug){ // ── Create / rename / archive board modals ────────────────────────────────── +async function _loadKanbanBoardWorkdirOptions(){ + await loadWorkspaceList(); + const list = document.getElementById('kanbanBoardModalWorkdirs'); + if (!list) return; + list.innerHTML = (_workspaceList || []).map(ws => { + const path = typeof ws === 'string' ? ws : ws && ws.path; + return path ? `` : ''; + }).join(''); +} + function openKanbanCreateBoard(){ const modal = document.getElementById('kanbanBoardModal'); if (!modal) return; @@ -4048,6 +4051,8 @@ function openKanbanCreateBoard(){ document.getElementById('kanbanBoardModalDesc').value = ''; document.getElementById('kanbanBoardModalIcon').value = ''; document.getElementById('kanbanBoardModalColor').value = '#7aa2ff'; + document.getElementById('kanbanBoardModalDefaultWorkdir').value = ''; + _loadKanbanBoardWorkdirOptions(); document.getElementById('kanbanBoardModalError').textContent = ''; modal.hidden = false; if (_kanbanBoardModalFocusCleanup) { @@ -4078,7 +4083,6 @@ function openKanbanRenameBoard(){ const modal = document.getElementById('kanbanBoardModal'); if (!modal) return; const current = _kanbanCurrentBoard || 'default'; - if (current === 'default') return; // default's slug is immutable const meta = (_kanbanBoardsList || []).find(b => b.slug === current); if (!meta) return; document.getElementById('kanbanBoardModalMode').value = 'rename'; @@ -4092,6 +4096,8 @@ function openKanbanRenameBoard(){ document.getElementById('kanbanBoardModalDesc').value = meta.description || ''; document.getElementById('kanbanBoardModalIcon').value = meta.icon || ''; document.getElementById('kanbanBoardModalColor').value = meta.color || '#7aa2ff'; + document.getElementById('kanbanBoardModalDefaultWorkdir').value = meta.default_workdir || ''; + _loadKanbanBoardWorkdirOptions(); document.getElementById('kanbanBoardModalError').textContent = ''; modal.hidden = false; if (_kanbanBoardModalFocusCleanup) { @@ -4126,6 +4132,7 @@ async function submitKanbanBoardModal(){ const description = (document.getElementById('kanbanBoardModalDesc').value || '').trim(); const icon = (document.getElementById('kanbanBoardModalIcon').value || '').trim(); const color = (document.getElementById('kanbanBoardModalColor').value || '').trim(); + const defaultWorkdir = (document.getElementById('kanbanBoardModalDefaultWorkdir').value || '').trim(); const submitBtn = document.getElementById('kanbanBoardModalSubmit'); if (!name) { errEl.textContent = t('kanban_board_name_required') || 'Name is required'; @@ -4138,9 +4145,11 @@ async function submitKanbanBoardModal(){ } if (submitBtn) submitBtn.disabled = true; try { + const payload = {slug: slugInput, name, description, icon, color, switch: true}; + if (defaultWorkdir) payload.default_workdir = defaultWorkdir; const res = await api('/api/kanban/boards', { method: 'POST', - body: JSON.stringify({slug: slugInput, name, description, icon, color, switch: true}), + body: JSON.stringify(payload), }); closeKanbanBoardModal(); // Switch to the new board and reload @@ -4164,7 +4173,7 @@ async function submitKanbanBoardModal(){ try { await api('/api/kanban/boards/' + encodeURIComponent(slug), { method: 'PATCH', - body: JSON.stringify({name, description, icon, color}), + body: JSON.stringify({name, description, icon, color, default_workdir: defaultWorkdir}), }); closeKanbanBoardModal(); await loadKanbanBoards(); // refresh switcher label/icon diff --git a/tests/test_issue5932_kanban_board_default_workdir.py b/tests/test_issue5932_kanban_board_default_workdir.py new file mode 100644 index 00000000..86c46db9 --- /dev/null +++ b/tests/test_issue5932_kanban_board_default_workdir.py @@ -0,0 +1,123 @@ +"""Regression coverage for Kanban board default workdir plumbing.""" + +import importlib +import re +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +INDEX = (ROOT / "static" / "index.html").read_text(encoding="utf-8") +PANELS = (ROOT / "static" / "panels.js").read_text(encoding="utf-8") +I18N = (ROOT / "static" / "i18n.js").read_text(encoding="utf-8") + + +class _Boards: + def __init__(self): + self.boards = {"default": {"slug": "default", "name": "Default", "default_workdir": "/old"}} + + def _normalize_board_slug(self, slug): + return str(slug).strip() + + def board_exists(self, slug): + return slug in self.boards + + def create_board(self, slug, **kwargs): + self.boards.setdefault(slug, {"slug": slug, "name": slug, "archived": False}) + self.boards[slug].update({key: value for key, value in kwargs.items() if value is not None}) + return dict(self.boards[slug]) + + def write_board_metadata(self, slug, **kwargs): + self.boards[slug].update({key: value for key, value in kwargs.items() if value is not None}) + return dict(self.boards[slug]) + + def get_current_board(self): + return "default" + + def set_current_board(self, slug): + return None + + +def _bridge(monkeypatch): + bridge = importlib.import_module("api.kanban_bridge") + boards = _Boards() + monkeypatch.setattr(bridge, "_kb", lambda: boards) + return bridge, boards + + +def test_board_payload_validates_default_workdir_and_preserves_omission(monkeypatch): + bridge, boards = _bridge(monkeypatch) + resolved = Path("/trusted/project") + calls = [] + monkeypatch.setattr(bridge, "resolve_trusted_workspace", lambda value: calls.append(value) or resolved) + + created = bridge._create_board_payload({"slug": "project", "default_workdir": " /saved/project "}) + assert created["board"]["default_workdir"] == str(resolved) + assert calls == ["/saved/project"] + + bridge._update_board_payload("project", {"name": "Project"}) + assert boards.boards["project"]["default_workdir"] == str(resolved) + bridge._update_board_payload("project", {"default_workdir": ""}) + assert boards.boards["project"]["default_workdir"] == "" + assert calls == ["/saved/project"] + + monkeypatch.setattr(bridge, "resolve_trusted_workspace", lambda value: (_ for _ in ()).throw(ValueError("untrusted"))) + try: + bridge._update_board_payload("project", {"default_workdir": "/outside"}) + except ValueError as exc: + assert "untrusted" in str(exc) + else: + raise AssertionError("untrusted workdir must be rejected") + + +def test_board_modal_submit_includes_and_clears_default_workdir(): + assert 'id="kanbanBoardModalDefaultWorkdir"' in INDEX + assert 'list="kanbanBoardModalWorkdirs"' in INDEX + assert "if (defaultWorkdir) payload.default_workdir = defaultWorkdir;" in PANELS + assert "default_workdir: defaultWorkdir" in PANELS + assert "default_workdir" not in PANELS[PANELS.find("createKanbanTask"):PANELS.find("createKanbanTask") + 5000] + assert "async function _loadKanbanBoardWorkdirOptions(){\n await loadWorkspaceList();" in PANELS + + +def test_default_board_settings_remain_reachable(): + assert "switcher.hidden = false;" in PANELS + assert "const archiveDisabled = current === 'default';" in PANELS + assert "if (current === 'default') return; // default's slug is immutable" not in PANELS + menu = PANELS[PANELS.find("const actions = `"):PANELS.find("const actions = `") + 1800] + assert "openKanbanRenameBoard()" in menu + assert "renameDisabled" not in menu + + +def test_new_board_keys_are_present_in_every_locale_block(): + blocks = re.findall(r"\n\s*(?:['\"][a-z]{2}(?:-[A-Z][A-Za-z]+)?['\"]|[a-z]{2}(?:-[A-Z]{2})?)\s*:\s*\{(.*?)\n\s*\},", I18N, re.S) + assert len(blocks) >= 14 + for block in blocks: + assert "kanban_board_default_workdir:" in block + assert "kanban_board_default_workdir_placeholder:" in block + + +def _locale_for_snippet(snippet): + index = I18N.find(snippet) + assert index != -1, f"missing snippet: {snippet}" + locale = None + for match in re.finditer( + r"^\s*(?:'(?P[a-z]{2}(?:-[A-Z][A-Za-z]+)?)'|(?P[a-z]{2}(?:-[A-Z]{2})?))\s*:\s*\{", + I18N, + re.M, + ): + if match.start() > index: + break + locale = match.group("quoted") or match.group("plain") + assert locale, f"missing locale opener before snippet: {snippet}" + return locale + + +def test_new_board_keys_are_localized_in_non_english_sample_locales(): + samples = { + "es": "Directorio de trabajo predeterminado", + "de": "Standard-Arbeitsverzeichnis", + "pt": "Diretório de trabalho padrão", + "ko": "기본 작업 디렉터리", + } + for locale, expected in samples.items(): + snippet = f"kanban_board_default_workdir: '{expected}'" + assert _locale_for_snippet(snippet) == locale diff --git a/tests/test_issue5932_kanban_board_default_workdir_layout.py b/tests/test_issue5932_kanban_board_default_workdir_layout.py new file mode 100644 index 00000000..f1c18ef7 --- /dev/null +++ b/tests/test_issue5932_kanban_board_default_workdir_layout.py @@ -0,0 +1,59 @@ +"""Rendered proof for the board default-workdir modal row.""" + +import pytest + +from tests._layout_helpers import assert_layout_sane, assert_no_raw_i18n_keys +from tests._pytest_port import BASE + + +EXPECTED_LABELS = { + "en": "Default workdir", + "ru": "Рабочая папка по умолчанию", + "de": "Standard-Arbeitsverzeichnis", +} +_BROWSER_ARGS = ["--no-sandbox", "--disable-dev-shm-usage"] + + +@pytest.mark.parametrize("locale", ["en", "ru", "de"]) +@pytest.mark.parametrize("width,height", [(1280, 800), (768, 800), (400, 800), (1024, 600), (480, 320)]) +def test_board_modal_default_workdir_layout(locale, width, height): + pw = pytest.importorskip("playwright.sync_api") + with pw.sync_playwright() as playwright: + browser = playwright.chromium.launch(headless=True, args=_BROWSER_ARGS) + page = browser.new_page(viewport={"width": width, "height": height}) + page.goto(BASE + "/", wait_until="domcontentloaded") + page.wait_for_selector("#kanbanBoardModalDefaultWorkdir", state="attached", timeout=10000) + page.wait_for_function( + "typeof setLocale === 'function' && typeof applyLocaleToDOM === 'function' && typeof openKanbanCreateBoard === 'function'", + timeout=10000, + ) + page.evaluate("""([lang]) => { + setLocale(lang); + applyLocaleToDOM(); + openKanbanCreateBoard(); + const row = document.getElementById('kanbanBoardModalDefaultWorkdir')?.closest('.kanban-modal-row'); + if (row) row.id = 'kanbanBoardModalDefaultWorkdirRow'; + }""", [locale]) + page.wait_for_function("""([expected]) => { + const modal = document.getElementById('kanbanBoardModal'); + const label = document.querySelector("label[for='kanbanBoardModalDefaultWorkdir']"); + const row = document.getElementById('kanbanBoardModalDefaultWorkdirRow'); + return !!modal && !modal.hidden && !!label && !!row && label.textContent.trim() === expected; + }""", arg=[EXPECTED_LABELS[locale]]) + modal = page.locator("#kanbanBoardModal") + row = page.locator("#kanbanBoardModalDefaultWorkdirRow") + field = page.locator("#kanbanBoardModalDefaultWorkdir") + assert row.is_visible() + assert field.is_visible() + label = page.locator("label[for='kanbanBoardModalDefaultWorkdir']") + assert label.is_visible() + assert label.text_content().strip() == EXPECTED_LABELS[locale] + modal_box = modal.bounding_box() + field_box = field.bounding_box() + assert modal_box and field_box + assert field_box["x"] >= modal_box["x"] + assert field_box["x"] + field_box["width"] <= modal_box["x"] + modal_box["width"] + assert field_box["y"] + field_box["height"] <= modal_box["y"] + modal_box["height"] + assert_no_raw_i18n_keys(page, "#kanbanBoardModalDefaultWorkdirRow") + assert_layout_sane(page, "#kanbanBoardModalDefaultWorkdirRow") + browser.close()