Enter a directory path, URL, or glob pattern (e.g. ~/projects/* or s3://bucket/prefix). Use Storage options to supply credentials or other fsspec options for remote filesystems.
+
+
+
+
+
+
+
+
+
+
+
Create spec
@@ -147,6 +169,63 @@ body { margin: 0; padding: 0; font-family: var(--vscode-font-family); color: var
border: none; padding: 4px 10px; cursor: pointer; font-size: 12px; border-radius: 3px;
}
.toolbar button:hover { background: var(--vscode-button-hoverBackground); }
+.add-group { position: relative; display: flex; }
+.add-group #btn-add { border-radius: 3px 0 0 3px; }
+.add-group #btn-add-chevron { border-radius: 0 3px 3px 0; padding: 4px 6px; border-left: 1px solid var(--vscode-panel-border); }
+.add-dropdown {
+ position: absolute; top: 100%; left: 0; z-index: 100; min-width: 160px;
+ background: var(--vscode-menu-background, var(--vscode-editorWidget-background));
+ color: var(--vscode-menu-foreground, var(--vscode-foreground));
+ border: 1px solid var(--vscode-menu-border, var(--vscode-panel-border));
+ border-radius: 3px; padding: 4px 0;
+ box-shadow: 0 2px 6px rgba(0,0,0,0.4);
+}
+.add-dropdown .menu-item { padding: 5px 12px; cursor: pointer; font-size: 12px; white-space: nowrap; }
+.add-dropdown .menu-item:hover {
+ background: var(--vscode-list-activeSelectionBackground, var(--vscode-list-hoverBackground));
+ color: var(--vscode-list-activeSelectionForeground, var(--vscode-foreground));
+}
+#add-advanced-overlay {
+ position: fixed; inset: 0; background: rgba(0,0,0,0.4);
+ display: flex; align-items: center; justify-content: center;
+ z-index: 2000;
+}
+#add-advanced-modal {
+ background: var(--vscode-editorWidget-background);
+ color: var(--vscode-editorWidget-foreground, var(--vscode-foreground));
+ border: 1px solid var(--vscode-editorWidget-border, var(--vscode-panel-border));
+ border-radius: 6px; min-width: 420px; max-width: 85%;
+ box-shadow: 0 8px 24px rgba(0,0,0,0.5);
+ display: flex; flex-direction: column;
+}
+#add-advanced-title {
+ padding: 10px 14px; font-weight: bold; font-size: 14px;
+ border-bottom: 1px solid var(--vscode-panel-border);
+}
+#add-advanced-body { padding: 12px 14px; }
+.add-advanced-hint {
+ font-size: 11px; color: var(--vscode-descriptionForeground);
+ margin: 0 0 10px 0; line-height: 1.5;
+}
+.add-advanced-hint code {
+ font-family: monospace; background: rgba(255,255,255,0.05);
+ padding: 0 3px; border-radius: 2px;
+}
+#add-advanced-body label {
+ display: block; font-size: 12px; margin-bottom: 4px;
+ color: var(--vscode-descriptionForeground);
+}
+#add-advanced-path, #add-advanced-so {
+ width: 100%; box-sizing: border-box;
+ background: var(--vscode-input-background); color: var(--vscode-input-foreground);
+ border: 1px solid var(--vscode-input-border, transparent);
+ padding: 6px 8px; font-size: 13px; border-radius: 3px; outline: none;
+}
+#add-advanced-path:focus, #add-advanced-so:focus { border-color: var(--vscode-focusBorder); }
+#add-advanced-actions {
+ display: flex; justify-content: flex-end; gap: 6px;
+ padding: 10px 14px; border-top: 1px solid var(--vscode-panel-border);
+}
.search { padding: 6px 8px; border-bottom: 1px solid var(--vscode-panel-border); }
.search-wrap { position: relative; display: flex; align-items: center; }
@@ -183,6 +262,7 @@ body { margin: 0; padding: 0; font-family: var(--vscode-font-family); color: var
.project .title { font-weight: bold; margin-right: 24px; }
.project .url { font-size: 11px; color: var(--vscode-descriptionForeground); word-break: break-all; margin-top: 2px; }
.project .storage-opts { font-size: 11px; color: var(--vscode-descriptionForeground); margin-top: 2px; font-style: italic; }
+.project .meta { font-size: 11px; color: var(--vscode-descriptionForeground); margin-top: 2px; }
.project .chips { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 6px; }
.chip {
@@ -466,6 +546,24 @@ body { margin: 0; padding: 0; font-family: var(--vscode-font-family); color: var
return idx >= 0 ? stripped.slice(idx + 1) : stripped;
} catch (_) { return url; }
}
+ function fmtSize(bytes) {
+ const units = ['B', 'KB', 'MB', 'GB', 'TB'];
+ let n = parseFloat(bytes);
+ for (let i = 0; i < units.length; i++) {
+ if (n < 1024 || i === units.length - 1)
+ return (i === 0 ? n.toFixed(0) : n.toFixed(1)) + ' ' + units[i];
+ n /= 1024;
+ }
+ }
+ function fmtAge(ts) {
+ const days = Math.floor((Date.now() / 1000 - parseFloat(ts)) / 86400);
+ if (days === 0) return 'today';
+ if (days === 1) return 'yesterday';
+ if (days < 30) return days + ' days ago';
+ if (days < 365) return Math.floor(days / 30) + ' months ago';
+ const yrs = Math.floor(days / 365);
+ return yrs + ' year' + (yrs > 1 ? 's' : '') + ' ago';
+ }
function specDisplayName(snake) { return snake; }
function iconForSpec(snake) {
const entry = info && info.specs && info.specs[snake];
@@ -533,6 +631,23 @@ body { margin: 0; padding: 0; font-family: var(--vscode-font-family); color: var
wrap.appendChild(so);
}
+ const metaParts = [];
+ if (project.file_count != null && project.total_size != null)
+ metaParts.push(parseInt(project.file_count).toLocaleString() + ' files, ' + fmtSize(project.total_size));
+ if (project.is_writable != null)
+ metaParts.push(project.is_writable === 'True' ? 'writable' : 'read-only');
+ if (project.last_modified != null) {
+ const age = fmtAge(project.last_modified);
+ const by = project.last_modified_by != null ? project.last_modified_by : null;
+ metaParts.push('last modified ' + age + (by ? ' by ' + by : ''));
+ }
+ if (metaParts.length > 0) {
+ const meta = document.createElement('div');
+ meta.className = 'meta';
+ meta.textContent = metaParts.join(' Β· ');
+ wrap.appendChild(meta);
+ }
+
const chips = document.createElement('div');
chips.className = 'chips';
const contents = project.contents || {};
@@ -1122,7 +1237,61 @@ body { margin: 0; padding: 0; font-family: var(--vscode-font-family); color: var
});
// ----- toolbar -----
- document.getElementById('btn-add').addEventListener('click', () => vscode.postMessage({ cmd: 'add' }));
+ const addDropdown = document.getElementById('add-dropdown');
+ document.getElementById('btn-add').addEventListener('click', () => {
+ addDropdown.classList.add('hidden');
+ vscode.postMessage({ cmd: 'add' });
+ });
+ document.getElementById('btn-add-chevron').addEventListener('click', (e) => {
+ e.stopPropagation();
+ addDropdown.classList.toggle('hidden');
+ });
+ document.getElementById('add-local').addEventListener('click', () => {
+ addDropdown.classList.add('hidden');
+ vscode.postMessage({ cmd: 'add' });
+ });
+ document.getElementById('add-advanced').addEventListener('click', () => {
+ addDropdown.classList.add('hidden');
+ openAddAdvancedModal();
+ });
+ document.addEventListener('click', () => addDropdown.classList.add('hidden'));
+
+ // ----- advanced-add modal -----
+ const addAdvancedOverlay = document.getElementById('add-advanced-overlay');
+ const addAdvancedPath = document.getElementById('add-advanced-path');
+ const addAdvancedSo = document.getElementById('add-advanced-so');
+ const addAdvancedOk = document.getElementById('add-advanced-ok');
+ const addAdvancedCancel = document.getElementById('add-advanced-cancel');
+
+ function openAddAdvancedModal() {
+ addAdvancedPath.value = '';
+ addAdvancedSo.value = '';
+ addAdvancedOverlay.classList.remove('hidden');
+ setTimeout(() => addAdvancedPath.focus(), 0);
+ }
+ function closeAddAdvancedModal() {
+ addAdvancedOverlay.classList.add('hidden');
+ }
+ function submitAddAdvanced() {
+ const p = addAdvancedPath.value.trim();
+ if (!p) return;
+ const so = addAdvancedSo.value.trim();
+ closeAddAdvancedModal();
+ vscode.postMessage({ cmd: 'addConfirmed', path: p, storageOptions: so });
+ }
+ addAdvancedOk.addEventListener('click', submitAddAdvanced);
+ addAdvancedCancel.addEventListener('click', closeAddAdvancedModal);
+ addAdvancedOverlay.addEventListener('click', (e) => {
+ if (e.target === addAdvancedOverlay) closeAddAdvancedModal();
+ });
+ addAdvancedPath.addEventListener('keydown', (e) => {
+ if (e.key === 'Enter') submitAddAdvanced();
+ if (e.key === 'Escape') closeAddAdvancedModal();
+ });
+ addAdvancedSo.addEventListener('keydown', (e) => {
+ if (e.key === 'Enter') submitAddAdvanced();
+ if (e.key === 'Escape') closeAddAdvancedModal();
+ });
document.getElementById('btn-reload').addEventListener('click', () => vscode.postMessage({ cmd: 'reload' }));
document.getElementById('btn-configure').addEventListener('click', () => vscode.postMessage({ cmd: 'configure' }));
searchEl.addEventListener('input', () => render());
diff --git a/src/projspec/__init__.py b/src/projspec/__init__.py
index bed4dba..f3f94e2 100644
--- a/src/projspec/__init__.py
+++ b/src/projspec/__init__.py
@@ -3,6 +3,6 @@
import projspec.content
import projspec.artifact
import projspec.config
-from projspec.utils import get_cls
+from projspec.utils import get_cls, scan_glob
-__all__ = ["Project", "ProjectSpec", "get_cls"]
+__all__ = ["Project", "ProjectSpec", "get_cls", "scan_glob"]
diff --git a/src/projspec/__main__.py b/src/projspec/__main__.py
index 53505cc..ada5cb6 100755
--- a/src/projspec/__main__.py
+++ b/src/projspec/__main__.py
@@ -73,7 +73,7 @@ def version():
@main.command("scan")
-@click.argument("path", default=".")
+@click.argument("patterns", nargs=-1)
@click.option(
"--storage_options",
default="",
@@ -101,11 +101,13 @@ def version():
default=False,
help="HTML output, for projects only",
)
-@click.option("--walk", is_flag=True, help="To descend into all child directories")
+@click.option(
+ "--walk", is_flag=True, help="Descend into child directories of each match"
+)
@click.option("--summary", is_flag=True, help="Show abbreviated output")
-@click.option("--library", is_flag=True, help="Add to library")
+@click.option("--library", is_flag=True, help="Add each result to the library")
def scan(
- path,
+ patterns,
storage_options,
types,
xtypes,
@@ -115,32 +117,39 @@ def scan(
summary,
library,
):
- """Scan the given path for projects, and display
+ """Scan directories and display results.
- path: str, path to the project directory, defaults to "."
+ PATTERNS is one or more directory paths or glob expressions. When the
+ shell expands a glob before invoking projspec, each expanded path is
+ passed as a separate argument. Glob expressions containing wildcards
+ (e.g. '~/projects/*') are expanded by projspec itself via fsspec.
+ If no PATTERNS are given, the current directory is scanned.
"""
+ from projspec.utils import scan_glob
+
if types in {"ALL", ""}:
types = None
else:
types = types.split(",")
- proj = projspec.Project(
- path,
- storage_options=storage_options,
- types=types,
- xtypes=xtypes,
- walk=walk,
- )
- if summary:
- print(proj.text_summary())
- else:
- if json_out:
- print(json.dumps(proj.to_dict(compact=False)))
- elif html_out:
- print(proj._repr_html_())
- else:
- print(proj)
- if library:
- proj.add_to_library()
+
+ for pattern in patterns or (".",):
+ for proj in scan_glob(
+ pattern,
+ types=types,
+ xtypes=xtypes,
+ walk=walk,
+ storage_options=storage_options,
+ add_to_library=library,
+ ):
+ if summary:
+ print(proj.text_summary())
+ else:
+ if json_out:
+ print(json.dumps(proj.to_dict(compact=False)))
+ elif html_out:
+ print(proj._repr_html_())
+ else:
+ print(proj)
@main.command("info")
diff --git a/src/projspec/config.py b/src/projspec/config.py
index 8dc2df8..7c0d2e5 100644
--- a/src/projspec/config.py
+++ b/src/projspec/config.py
@@ -39,6 +39,18 @@ def defaults():
"remote_artifact_status": False,
"capture_artifact_output": True,
"preferred_install_methods": ["conda", "pip"],
+ "excludes": [
+ "bld",
+ "build",
+ "dist",
+ "env",
+ "envs",
+ "htmlcov",
+ "node_modules",
+ "site",
+ "target",
+ "venv",
+ ],
}
@@ -56,6 +68,11 @@ def defaults():
"ordered list of preferred installer names for install_tool(), "
"e.g. ['uv', 'conda', 'pip']. Empty list uses the platform default."
),
+ "excludes": (
+ "directory names to skip when walking a project tree for child projects "
+ "and file statistics. Directories whose names start with '.' or '_' are "
+ "always skipped regardless of this setting."
+ ),
}
@@ -124,12 +141,28 @@ def save_conf(conf: dict):
@contextmanager
def temp_conf(**kwargs):
- """Temporarily set the config"""
- old = conf.copy()
- # TODO: only allow keys that exist in defaults()?
+ """Temporarily set the config in memory and on disk; both are restored on exit."""
+ conf_file = f"{conf_dir()}/projspec.json"
+ old_mem = conf.copy()
+ # Snapshot the on-disk file so we can restore it even if set_conf() writes it.
+ try:
+ with open(conf_file) as _f:
+ old_disk: str | None = _f.read()
+ except FileNotFoundError:
+ old_disk = None
conf.update(kwargs)
try:
yield
finally:
conf.clear()
- conf.update(old)
+ conf.update(old_mem)
+ # Restore (or remove) the config file to its pre-context state.
+ if old_disk is None:
+ try:
+ os.unlink(conf_file)
+ except FileNotFoundError:
+ pass
+ else:
+ os.makedirs(conf_dir(), exist_ok=True)
+ with open(conf_file, "w") as _f:
+ _f.write(old_disk)
diff --git a/src/projspec/content/__init__.py b/src/projspec/content/__init__.py
index 6c38261..4c02338 100644
--- a/src/projspec/content/__init__.py
+++ b/src/projspec/content/__init__.py
@@ -11,9 +11,9 @@
from projspec.content.env_var import EnvironmentVariables
from projspec.content.environment import Environment, Stack, Precision
from projspec.content.executable import Command
-
from projspec.content.metadata import DescriptiveMetadata, License
from projspec.content.package import PythonPackage
+from projspec.content.vcs import VCSInfo
__all__ = [
@@ -32,4 +32,5 @@
"Environment",
"Stack",
"Precision",
+ "VCSInfo",
]
diff --git a/src/projspec/content/vcs.py b/src/projspec/content/vcs.py
new file mode 100644
index 0000000..a415a1f
--- /dev/null
+++ b/src/projspec/content/vcs.py
@@ -0,0 +1,70 @@
+"""VCSInfo content class β normalised version-control metadata."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+
+from projspec.content.base import BaseContent
+
+
+@dataclass
+class VCSInfo(BaseContent):
+ """Normalised metadata extracted from a VCS repository directory.
+
+ All three VCS specs (``GitRepo``, ``HgRepo``, ``FossilRepo``) produce a
+ single ``VCSInfo`` instance stored under the ``"vcs_info"`` key in their
+ ``_contents``.
+
+ Standard fields
+ ---------------
+ vcs : str
+ VCS tool name: ``"git"``, ``"hg"``, or ``"fossil"``.
+ branch : str or None
+ Current branch / bookmark name.
+ commit : str or None
+ Short commit hash or revision identifier.
+ author : str or None
+ Author of the most recent commit.
+ message : str or None
+ First line of the most recent commit message.
+ timestamp : float or None
+ Unix timestamp of the most recent commit.
+
+ VCS-specific extras
+ -------------------
+ For **git**: ``extra`` may contain ``"branches"`` (list), ``"tags"``
+ (list), and ``"remote_names"`` (list).
+
+ For **Mercurial**: ``extra`` may contain ``"bookmarks"`` (list) and
+ ``"remotes"`` (dict mapping name β URL).
+
+ For **Fossil**: ``extra`` may contain ``"repository"`` (str path to the
+ ``.fossil`` database file).
+
+ Summary
+ -------
+ The ``summary`` property returns a plain :class:`dict` with the subset
+ of fields that have non-``None`` values β identical in shape to what
+ ``Project.vcs_info`` exposes. It is included in ``to_dict()`` output
+ so it is preserved when a project is saved to the library.
+ """
+
+ icon = "π"
+
+ vcs: str = ""
+ branch: str | None = None
+ commit: str | None = None
+ author: str | None = None
+ message: str | None = None
+ timestamp: float | None = None
+ extra: dict = field(default_factory=dict)
+
+ @property
+ def summary(self) -> dict:
+ """Plain dict of all non-None standard VCS fields (including ``vcs``)."""
+ out: dict = {"vcs": self.vcs}
+ for key in ("branch", "commit", "author", "message", "timestamp"):
+ val = getattr(self, key)
+ if val is not None:
+ out[key] = val
+ return out
diff --git a/src/projspec/proj/__init__.py b/src/projspec/proj/__init__.py
index 9fa1dfb..328cd80 100644
--- a/src/projspec/proj/__init__.py
+++ b/src/projspec/proj/__init__.py
@@ -39,7 +39,7 @@
Snakemake,
)
from projspec.proj.documentation import RTD, MDBook, MkDocs, Sphinx, Docusaurus
-from projspec.proj.git import GitRepo
+from projspec.proj.vcs import FossilRepo, GitRepo, HgRepo
from projspec.proj.golang import Golang
from projspec.proj.helm import HelmChart
from projspec.proj.hf import HuggingFaceRepo
@@ -112,9 +112,10 @@
"MDBook",
"RTD",
"Sphinx",
- # Git
+ # VCS
+ "FossilRepo",
"GitRepo",
- # Go
+ "HgRepo", # Go
"Golang",
# Helm/K8s
"HelmChart",
diff --git a/src/projspec/proj/base.py b/src/projspec/proj/base.py
index 403ec4c..25d5e7c 100644
--- a/src/projspec/proj/base.py
+++ b/src/projspec/proj/base.py
@@ -1,5 +1,8 @@
import io
+import json
import logging
+import os
+import stat
from collections.abc import Iterable
from itertools import chain
from functools import cached_property
@@ -22,19 +25,13 @@
logger = logging.getLogger("projspec")
registry = {}
-# we don't consider these as possible child projects when walk=True
-default_excludes = {
- # TODO: make this a conf
- # TODO: add more here
- # we always ignore directories starting with "." or "_"
- "bld",
- "build",
- "dist",
- "env",
- "envs", # conda-project
- "htmlcov",
- "node_modules",
-}
+
+def _fmt_size(n: int) -> str:
+ """Human-readable byte size, e.g. '3.2 MB'."""
+ for unit in ("B", "KB", "MB", "GB", "TB"):
+ if n < 1024 or unit == "TB":
+ return f"{n:.1f} {unit}" if unit != "B" else f"{n} B"
+ n /= 1024
class ParseFailed(ValueError):
@@ -69,7 +66,7 @@ def __init__(
the case that the root did not many any project type.
:param types: only allow specs whose names are included.
:param xtypes: disallow specs whose names are in this set
- :param excludes: directory names to ignore. If None, uses default_excludes.
+ :param excludes: directory names to ignore. If None, uses `excludes` config value
"""
if fs is None:
fs, path = fsspec.url_to_fs(path, **(storage_options or {}))
@@ -87,7 +84,7 @@ def __init__(
self.contents = AttrDict()
self.artifacts = AttrDict()
# read and respect .gitignore? for exclude directories?
- self.excludes = excludes or default_excludes
+ self.excludes = excludes if excludes is not None else set(get_conf("excludes"))
self._reset()
self.resolve(walk=walk, types=types, xtypes=xtypes)
@@ -97,6 +94,8 @@ def _reset(self):
self.__dict__.pop("basenames", None)
self.__dict__.pop("filelist", None)
self.__dict__.pop("pyproject", None)
+ self.__dict__.pop("_tree_stats", None)
+ self.__dict__.pop("vcs_info", None)
self._scanned_files = None
# clear cached files
self._scanned_files = None
@@ -106,6 +105,162 @@ def is_local(self) -> bool:
# see also fsspec.utils.can_be_local for more flexibility with caching.
return isinstance(self.fs, fsspec.implementations.local.LocalFileSystem)
+ @cached_property
+ def _tree_stats(self) -> dict:
+ """Walk the directory tree and collect aggregate statistics.
+
+ Returns a dict with keys:
+ file_count int β number of files found (directories excluded)
+ total_size int β total byte size of all files
+ is_writable bool | None β True/False/None (None = could not determine)
+ last_modified float | None β mtime of the most-recently-changed file, as a
+ Unix timestamp; None if unavailable
+ last_modified_by str | None β username (or uid string) of the owner of that
+ file; None if unavailable
+ """
+ file_count = 0
+ total_size = 0
+ best_mtime: float | None = None
+ best_info: dict | None = None
+
+ try:
+ for dirpath, subdirs, files in self.fs.walk(
+ self.url, topdown=True, detail=True
+ ):
+ # Prune excluded directories in-place (topdown=True makes this work)
+ # subdirs is a dict when detail=True
+ if isinstance(subdirs, dict):
+ to_remove = [
+ name
+ for name in list(subdirs)
+ if name in self.excludes or name.startswith((".", "_"))
+ ]
+ for name in to_remove:
+ del subdirs[name]
+ file_infos = files.values() if isinstance(files, dict) else []
+ else:
+ # Some backends yield lists even with detail=True; skip pruning
+ file_infos = []
+
+ for finfo in file_infos:
+ if not isinstance(finfo, dict):
+ continue
+ size = finfo.get("size") or 0
+ file_count += 1
+ total_size += size
+ mtime = finfo.get("mtime") or finfo.get("LastModified")
+ if mtime is not None:
+ # mtime may be a datetime; normalise to float
+ ts = (
+ mtime.timestamp()
+ if hasattr(mtime, "timestamp")
+ else float(mtime)
+ )
+ if best_mtime is None or ts > best_mtime:
+ best_mtime = ts
+ best_info = finfo
+ except Exception:
+ logger.debug("_tree_stats walk failed for %s", self.url, exc_info=True)
+
+ # ββ is_writable ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+ is_writable: bool | None = None
+ if self.is_local():
+ try:
+ is_writable = os.access(self.url, os.W_OK)
+ except Exception:
+ pass
+ else:
+ # For remote backends: inspect mode bits if present, otherwise None.
+ try:
+ root_info = self.fs.info(self.url)
+ mode = root_info.get("mode")
+ if mode is not None:
+ is_writable = bool(mode & stat.S_IWUSR)
+ # Some backends expose an explicit writable flag (e.g. SMB)
+ elif "writable" in root_info:
+ is_writable = bool(root_info["writable"])
+ except Exception:
+ pass
+
+ # ββ last_modified_by βββββββββββββββββββββββββββββββββββββββββββββββββ
+ last_modified_by: str | None = None
+ if best_info is not None:
+ uid = best_info.get("uid")
+ owner = best_info.get("owner") or best_info.get("Owner")
+ if uid is not None and self.is_local():
+ try:
+ import pwd
+
+ last_modified_by = pwd.getpwuid(int(uid)).pw_name
+ except Exception:
+ last_modified_by = str(uid)
+ elif owner is not None:
+ last_modified_by = str(owner)
+
+ return {
+ "file_count": file_count,
+ "total_size": total_size,
+ "is_writable": is_writable,
+ "last_modified": best_mtime,
+ "last_modified_by": last_modified_by,
+ }
+
+ @property
+ def file_count(self) -> int:
+ """Total number of files in the directory tree (excluding ignored directories)."""
+ return self._tree_stats["file_count"]
+
+ @property
+ def total_size(self) -> int:
+ """Total byte size of all files in the directory tree."""
+ return self._tree_stats["total_size"]
+
+ @property
+ def is_writable(self) -> bool | None:
+ """Whether the project root appears to be writable.
+
+ Returns True/False for local filesystems and backends that expose mode
+ bits or an explicit writable flag. Returns None when the information is
+ not available (e.g. read-only remote backends without metadata).
+ """
+ return self._tree_stats["is_writable"]
+
+ @property
+ def last_modified(self) -> float | None:
+ """Unix timestamp of the most recently modified file in the tree, or None."""
+ return self._tree_stats["last_modified"]
+
+ @property
+ def last_modified_by(self) -> str | None:
+ """Username (or uid string) of the owner of the most recently modified file.
+
+ Resolved via the ``pwd`` module on local filesystems. For remote backends
+ that expose an ``owner`` field in their file-info dict the raw value is
+ returned as a string. None when not available.
+ """
+ return self._tree_stats["last_modified_by"]
+
+ @cached_property
+ def vcs_info(self) -> dict | None:
+ """VCS metadata for this project, or ``None`` if no VCS was detected.
+
+ Returns the :attr:`~projspec.content.vcs.VCSInfo.summary` dict from
+ whichever VCS spec (``git_repo``, ``hg_repo``, or ``fossil_repo``) was
+ found. Keys present depend on what information was extractable from the
+ repository files without invoking any VCS binary.
+
+ The same data is stored in the ``VCSInfo`` content object inside the
+ matching spec's ``_contents["vcs_info"]`` and is therefore serialised
+ when the project is saved to the library.
+ """
+ for spec_name in ("git_repo", "hg_repo", "fossil_repo"):
+ spec = self.specs.get(spec_name)
+ if spec is not None:
+ vi = spec.contents.get("vcs_info")
+ if vi is not None:
+ return vi.summary
+ return None
+
@property
def scanned_files(self):
if self._scanned_files is None:
@@ -228,6 +383,8 @@ def text_summary(self, bare=False) -> str:
if bare
else f"\n"
)
+ if not bare:
+ txt += self._stats_line() + "\n"
bits = [
f" {'/'}: {' '.join(type(_).__name__ for _ in chain(self.specs.values(), self.contents.values(), self.artifacts.values()))}"
] + [
@@ -236,12 +393,52 @@ def text_summary(self, bare=False) -> str:
]
return txt + "\n".join(bits)
+ def _stats_line(self) -> str:
+ """One-line summary of filesystem statistics for this project."""
+ parts = []
+
+ # file count + size
+ count = self.file_count
+ size = self.total_size
+ if count or size:
+ parts.append(f"{count:,} files, {_fmt_size(size)}")
+
+ # writable
+ if self.is_writable is not None:
+ parts.append("writable" if self.is_writable else "read-only")
+
+ # last modified
+ lm = self.last_modified
+ if lm is not None:
+ import datetime
+
+ age = datetime.datetime.now() - datetime.datetime.fromtimestamp(lm)
+ days = age.days
+ if days == 0:
+ age_str = "today"
+ elif days == 1:
+ age_str = "yesterday"
+ elif days < 30:
+ age_str = f"{days} days ago"
+ elif days < 365:
+ age_str = f"{days // 30} months ago"
+ else:
+ age_str = f"{days // 365} year{'s' if days >= 730 else ''} ago"
+ by = self.last_modified_by
+ if by:
+ parts.append(f"last modified {age_str} by {by}")
+ else:
+ parts.append(f"last modified {age_str}")
+
+ return " " + " Β· ".join(parts) if parts else ""
+
def __repr__(self):
return f""
def __str__(self):
- txt = "\n\n{}".format(
+ txt = "\n{}\n\n{}".format(
self.fs.unstrip_protocol(self.url),
+ self._stats_line(),
"\n\n".join(str(_) for _ in self.specs.values()),
)
if self.contents or self.artifacts:
@@ -355,6 +552,11 @@ def to_dict(self, compact=True) -> dict:
storage_options=self.storage_options,
artifacts=self.artifacts,
contents=self.contents,
+ file_count=self.file_count,
+ total_size=self.total_size,
+ is_writable=self.is_writable,
+ last_modified=self.last_modified,
+ last_modified_by=self.last_modified_by,
)
if not compact:
dic["klass"] = "project"
@@ -381,6 +583,15 @@ def from_dict(dic):
proj.path = dic["url"]
proj.storage_options = dic["storage_options"]
proj.fs, proj.url = fsspec.url_to_fs(proj.path, **proj.storage_options)
+ # Restore cached tree stats so a round-tripped Project never re-walks.
+ # Keys default to None if absent (e.g. older serialised data).
+ proj.__dict__["_tree_stats"] = {
+ "file_count": dic.get("file_count", 0),
+ "total_size": dic.get("total_size", 0),
+ "is_writable": dic.get("is_writable"),
+ "last_modified": dic.get("last_modified"),
+ "last_modified_by": dic.get("last_modified_by"),
+ }
return proj
def create(self, name: str) -> list[str]:
@@ -433,10 +644,21 @@ def add_to_library(self, path=None):
"""Add this project to the current session library"""
# TODO: prevent overwrite?
from projspec.library import ProjectLibrary
+ import json
+ # precheck serialisability (prevents malformed JSON diring save)
+ json.dumps(self.to_dict(compact=False))
library = ProjectLibrary(path)
library.add_entry(self.fs.unstrip_protocol(self.url), self)
+ def __bool__(self):
+ return (
+ bool(self.specs)
+ or bool(self.children)
+ or bool(self.contents)
+ or bool(self.artifacts)
+ )
+
class ProjectSpec:
"""A project specification
diff --git a/src/projspec/proj/git.py b/src/projspec/proj/git.py
index f2af852..8f2837c 100644
--- a/src/projspec/proj/git.py
+++ b/src/projspec/proj/git.py
@@ -1,42 +1,6 @@
-from projspec.proj.base import ProjectSpec
-from projspec.utils import AttrDict, run_subprocess
+# Compatibility shim β GitRepo now lives in projspec.proj.vcs.
+# This module is kept so that existing pickled objects and any code that
+# does ``from projspec.proj.git import GitRepo`` continues to work.
+from projspec.proj.vcs import GitRepo
-
-class GitRepo(ProjectSpec):
- """A version controlled repository utilising git
-
- git is a very common version control system for code projects.
- """
-
- icon = "π"
- spec_doc = "https://git-scm.com/docs/git-config#_configuration_file"
-
- def match(self) -> bool:
- return ".git" in self.proj.basenames
-
- @staticmethod
- def _create(path: str) -> None:
- run_subprocess(["git", "init"], cwd=path, output=False)
-
- def parse(self) -> None:
- # It's faster to read the /.git/config file for branches, remotes and URLs;
- # that file i always present.
- cont = AttrDict()
- try:
- cont["remotes"] = [
- _.rsplit("/", 1)[-1]
- for _ in self.proj.fs.ls(
- f"{self.proj.url}/.git/refs/remotes", detail=False
- )
- ]
- except FileNotFoundError:
- pass
- cont["tags"] = [
- _.rsplit("/", 1)[-1]
- for _ in self.proj.fs.ls(f"{self.proj.url}/.git/refs/tags", detail=False)
- ]
- cont["branches"] = [
- _.rsplit("/", 1)[-1]
- for _ in self.proj.fs.ls(f"{self.proj.url}/.git/refs/heads", detail=False)
- ]
- self._contents = cont
+__all__ = ["GitRepo"]
diff --git a/src/projspec/proj/pixi.py b/src/projspec/proj/pixi.py
index 8a37e49..e03865e 100644
--- a/src/projspec/proj/pixi.py
+++ b/src/projspec/proj/pixi.py
@@ -98,7 +98,7 @@ def parse(self) -> None:
details if isinstance(details, list) else details["features"]
)
for feat_name in feats:
- feat.update(meta["feature"][feat_name])
+ feat.update(meta["feature"].get(feat_name, {}))
if isinstance(details, list) or not details.get("no-default-feature"):
feat.update(meta)
extract_feature(feat, procs, commands, self, env=env_name)
diff --git a/src/projspec/proj/vcs.py b/src/projspec/proj/vcs.py
new file mode 100644
index 0000000..b133816
--- /dev/null
+++ b/src/projspec/proj/vcs.py
@@ -0,0 +1,435 @@
+"""Version-control system specs: GitRepo, HgRepo, FossilRepo.
+
+All three specs produce a single :class:`~projspec.content.vcs.VCSInfo`
+content object under the ``"vcs_info"`` key, giving a uniform interface
+regardless of which VCS is in use. ``Project.vcs_info`` delegates directly
+to that object's :attr:`~projspec.content.vcs.VCSInfo.summary` property.
+"""
+
+from __future__ import annotations
+
+import re
+import struct
+import zlib
+
+from projspec.content.vcs import VCSInfo
+from projspec.proj.base import ParseFailed, ProjectSpec
+from projspec.utils import AttrDict, run_subprocess
+
+
+# ===========================================================================
+# GitRepo
+# ===========================================================================
+
+
+class GitRepo(ProjectSpec):
+ """A version-controlled repository using git.
+
+ git is the most widely used distributed VCS. Branch, commit, author,
+ and message are extracted from the ``.git/`` directory without requiring
+ the ``git`` binary.
+ """
+
+ icon = "π"
+ spec_doc = "https://git-scm.com/docs/git-config#_configuration_file"
+
+ def match(self) -> bool:
+ return ".git" in self.proj.basenames
+
+ @staticmethod
+ def _create(path: str) -> None:
+ run_subprocess(["git", "init"], cwd=path, output=False)
+
+ def parse(self) -> None:
+ info = _read_git_info(self.proj)
+ extra: dict = {}
+
+ # Collect refs lists (branches, tags, remote names)
+ try:
+ extra["remote_names"] = [
+ p.rsplit("/", 1)[-1]
+ for p in self.proj.fs.ls(
+ f"{self.proj.url}/.git/refs/remotes", detail=False
+ )
+ ]
+ except FileNotFoundError:
+ pass
+ extra["tags"] = []
+ try:
+ extra["tags"] = [
+ p.rsplit("/", 1)[-1]
+ for p in self.proj.fs.ls(
+ f"{self.proj.url}/.git/refs/tags", detail=False
+ )
+ ]
+ except FileNotFoundError:
+ pass
+ extra["branches"] = []
+ try:
+ extra["branches"] = [
+ p.rsplit("/", 1)[-1]
+ for p in self.proj.fs.ls(
+ f"{self.proj.url}/.git/refs/heads", detail=False
+ )
+ ]
+ except FileNotFoundError:
+ pass
+
+ self._contents = AttrDict(
+ vcs_info=VCSInfo(
+ proj=self.proj,
+ vcs="git",
+ branch=info.get("branch"),
+ commit=info.get("commit"),
+ author=info.get("author"),
+ message=info.get("message"),
+ timestamp=info.get("timestamp"),
+ extra=extra,
+ )
+ )
+
+
+# ===========================================================================
+# HgRepo
+# ===========================================================================
+
+
+class HgRepo(ProjectSpec):
+ """A version-controlled repository using Mercurial (hg).
+
+ Mercurial is a distributed VCS used heavily at Meta, Mozilla, and in
+ enterprise environments. Metadata is extracted from the ``.hg/``
+ directory without requiring the ``hg`` binary.
+ """
+
+ icon = "πͺ’"
+ spec_doc = "https://www.mercurial-scm.org/wiki/Repository"
+
+ def match(self) -> bool:
+ return ".hg" in self.proj.basenames
+
+ @staticmethod
+ def _create(path: str) -> None:
+ run_subprocess(["hg", "init"], cwd=path, output=False)
+
+ def parse(self) -> None:
+ extra: dict = {}
+ branch: str | None = None
+
+ # ββ branch ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+ try:
+ with self.proj.get_file(".hg/branch") as f:
+ branch = f.read().strip() or None
+ except (OSError, UnicodeDecodeError):
+ pass
+
+ # ββ bookmarks ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+ try:
+ with self.proj.get_file(".hg/bookmarks") as f:
+ lines = f.read().splitlines()
+ extra["bookmarks"] = [ln.split()[-1] for ln in lines if ln.strip()]
+ except OSError:
+ extra["bookmarks"] = []
+
+ # ββ remotes ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+ try:
+ import configparser
+
+ cp = configparser.RawConfigParser()
+ with self.proj.get_file(".hg/hgrc") as f:
+ cp.read_string(f.read())
+ if cp.has_section("paths"):
+ extra["remotes"] = dict(cp.items("paths"))
+ except Exception:
+ extra["remotes"] = {}
+
+ # ββ most-recent commit βββββββββββββββββββββββββββββββββββββββββββββββ
+ commit_meta: dict = {}
+ try:
+ result = _read_last_hg_commit(self.proj)
+ if result:
+ commit_meta = result
+ except Exception:
+ pass
+
+ if not branch and not commit_meta and not extra.get("bookmarks"):
+ raise ParseFailed("No usable Mercurial metadata found")
+
+ self._contents = AttrDict(
+ vcs_info=VCSInfo(
+ proj=self.proj,
+ vcs="hg",
+ branch=branch,
+ commit=commit_meta.get("commit"),
+ author=commit_meta.get("author"),
+ message=commit_meta.get("message"),
+ timestamp=commit_meta.get("timestamp"),
+ extra=extra,
+ )
+ )
+
+
+# ===========================================================================
+# FossilRepo
+# ===========================================================================
+
+_CHECKOUT_NAMES = ("_FOSSIL_", ".fslckout")
+
+
+class FossilRepo(ProjectSpec):
+ """A version-controlled repository using Fossil SCM.
+
+ Fossil is an integrated VCS + bug-tracker + wiki used by the SQLite
+ project and others. The checkout database is a standard SQLite3 file,
+ so metadata is extracted without requiring the ``fossil`` binary.
+ """
+
+ icon = "π¦΄"
+ spec_doc = "https://fossil-scm.org/home/doc/trunk/www/fileformat.wiki"
+
+ def match(self) -> bool:
+ return any(name in self.proj.basenames for name in _CHECKOUT_NAMES)
+
+ @staticmethod
+ def _create(path: str) -> None:
+ import os
+
+ repo_file = os.path.join(path, "repo.fossil")
+ run_subprocess(["fossil", "init", repo_file], output=False)
+ run_subprocess(["fossil", "open", repo_file], cwd=path, output=False)
+
+ def parse(self) -> None:
+ import sqlite3
+ import tempfile
+ import os
+
+ db_name = next((n for n in _CHECKOUT_NAMES if n in self.proj.basenames), None)
+ if db_name is None:
+ raise ParseFailed("No Fossil checkout database found")
+
+ db_path = self.proj.basenames[db_name]
+ if self.proj.is_local():
+ local_path = db_path
+ cleanup = False
+ else:
+ with self.proj.get_file(db_name, text=False) as f:
+ data = f.read()
+ tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".fossil")
+ tmp.write(data)
+ tmp.close()
+ local_path = tmp.name
+ cleanup = True
+
+ try:
+ try:
+ con = sqlite3.connect(local_path)
+ except Exception:
+ raise ParseFailed("Could not open Fossil checkout database")
+ try:
+ raw = _query_fossil_db(con)
+ finally:
+ con.close()
+ finally:
+ if cleanup:
+ try:
+ os.unlink(local_path)
+ except OSError:
+ pass
+
+ if not raw:
+ raise ParseFailed("No usable Fossil metadata found")
+
+ extra: dict = {}
+ if "repository" in raw:
+ extra["repository"] = raw["repository"]
+
+ self._contents = AttrDict(
+ vcs_info=VCSInfo(
+ proj=self.proj,
+ vcs="fossil",
+ branch=raw.get("branch"),
+ commit=raw.get("commit"),
+ author=raw.get("author"),
+ message=raw.get("message"),
+ timestamp=raw.get("timestamp"),
+ extra=extra,
+ )
+ )
+
+
+# ===========================================================================
+# Private helpers
+# ===========================================================================
+
+
+def _read_git_info(proj) -> dict:
+ """Extract HEAD commit metadata from a .git directory.
+
+ Returns a dict with any subset of ``branch``, ``commit``, ``author``,
+ ``message``, ``timestamp``. Never raises.
+ """
+ info: dict = {}
+ try:
+ with proj.get_file(".git/HEAD") as f:
+ head = f.read().strip()
+ if head.startswith("ref:"):
+ ref = head.split("ref:", 1)[1].strip()
+ info["branch"] = ref.split("refs/heads/", 1)[-1]
+ try:
+ with proj.get_file(f".git/{ref}") as f:
+ info["commit"] = f.read().strip()[:12]
+ except OSError:
+ pass
+ else:
+ info["branch"] = "(detached HEAD)"
+ info["commit"] = head[:12]
+ except OSError:
+ return info
+
+ # Author + message + timestamp from reflog
+ try:
+ with proj.get_file(".git/logs/HEAD") as f:
+ lines = f.read().splitlines()
+ if lines:
+ last = lines[-1]
+ m = re.match(r"[0-9a-f]+ [0-9a-f]+ (.*?) \d+ [+-]\d+\t(.*)", last)
+ if m:
+ info["author"] = m.group(1).strip()
+ info["message"] = m.group(2).strip().splitlines()[0]
+ ts_m = re.search(r"\s(\d{10})\s[+-]\d{4}\t", last)
+ if ts_m:
+ info["timestamp"] = float(ts_m.group(1))
+ except OSError:
+ pass
+
+ # Fallback message from COMMIT_EDITMSG
+ if "message" not in info:
+ try:
+ with proj.get_file(".git/COMMIT_EDITMSG") as f:
+ raw = f.read()
+ msg_lines = [l for l in raw.splitlines() if not l.startswith("#")]
+ if msg_lines:
+ info["message"] = msg_lines[0].strip()
+ except OSError:
+ pass
+
+ return info
+
+
+# ββ Mercurial revlog ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+_RECORD_SIZE = 64 # bytes per revlog index entry
+
+
+def _read_last_hg_commit(proj) -> dict | None:
+ """Parse the last entry from .hg/store/00changelog.{i,d}.
+
+ Revlog v1 index record layout (big-endian, 64 bytes):
+ offset+flags 8 B β high 6 bytes: offset in .d file, low 2: flags
+ comp_len 4 B β compressed length
+ uncomp_len 4 B
+ base_rev 4 B
+ link_rev 4 B
+ parent1 4 B (signed)
+ parent2 4 B (signed)
+ nodeid 32 B β 20-byte SHA1 + 12 bytes padding
+ """
+ try:
+ with proj.get_file(".hg/store/00changelog.i", text=False) as f:
+ index_data = f.read()
+ except OSError:
+ return None
+
+ n = len(index_data) // _RECORD_SIZE
+ if n == 0:
+ return None
+
+ last = index_data[(n - 1) * _RECORD_SIZE : n * _RECORD_SIZE]
+ if len(last) < _RECORD_SIZE:
+ return None
+
+ offset_flags, comp_len = struct.unpack_from(">QI", last, 0)
+ offset = 0 if n == 1 else (offset_flags >> 16)
+ nodeid = last[32:52].hex()
+
+ try:
+ with proj.get_file(".hg/store/00changelog.d", text=False) as f:
+ f.seek(offset)
+ raw = f.read(comp_len)
+ except (OSError, AttributeError):
+ return {"commit": nodeid[:12]}
+
+ entry = _decompress_revlog_entry(raw)
+ if entry is None:
+ return {"commit": nodeid[:12]}
+ return _parse_changelog_entry(entry, nodeid)
+
+
+def _decompress_revlog_entry(raw: bytes) -> bytes | None:
+ if not raw:
+ return None
+ tag = raw[0:1]
+ if tag == b"u":
+ return raw[1:]
+ if tag == b"x":
+ try:
+ return zlib.decompress(raw[1:])
+ except zlib.error:
+ return None
+ return None # zstd or delta β not handled
+
+
+def _parse_changelog_entry(data: bytes, nodeid: str) -> dict:
+ """Parse a decompressed Mercurial changelog entry.
+
+ Format: ``\\n\\n\\n\\n\\n``
+ """
+ result: dict = {"commit": nodeid[:12]}
+ try:
+ text = data.decode("utf-8", errors="replace")
+ lines = text.split("\n")
+ if len(lines) >= 2:
+ result["author"] = lines[1].strip()
+ if len(lines) >= 3:
+ result["timestamp"] = float(lines[2].split()[0])
+ if "\n\n" in text:
+ result["message"] = text.split("\n\n", 1)[-1].strip()
+ except Exception:
+ pass
+ return result
+
+
+# ββ Fossil SQLite βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+
+def _query_fossil_db(con) -> dict:
+ """Query a Fossil checkout SQLite database for metadata."""
+ result: dict = {}
+ try:
+ cur = con.execute(
+ "SELECT name, value FROM vvar WHERE name IN "
+ "('checkout','checkout-hash','branch','repository')"
+ )
+ for name, value in cur.fetchall():
+ if name in ("checkout", "checkout-hash"):
+ result["commit"] = str(value)[:12]
+ elif name == "branch":
+ result["branch"] = str(value)
+ elif name == "repository":
+ result["repository"] = str(value)
+ except Exception:
+ pass
+
+ try:
+ row = con.execute(
+ "SELECT user, comment, mtime FROM event "
+ "WHERE type='ci' ORDER BY mtime DESC LIMIT 1"
+ ).fetchone()
+ if row:
+ result["author"] = str(row[0])
+ result["message"] = str(row[1]).strip()
+ result["timestamp"] = (float(row[2]) - 2440587.5) * 86400
+ except Exception:
+ pass
+
+ return result
diff --git a/src/projspec/qtapp/main.py b/src/projspec/qtapp/main.py
index 397536e..5f4724e 100644
--- a/src/projspec/qtapp/main.py
+++ b/src/projspec/qtapp/main.py
@@ -516,6 +516,7 @@ def main() -> None:
print("No Qt bindings found - cannot continue")
return
app = QApplication(sys.argv)
+ app.setApplicationName("projspec")
icon_path = os.path.join(os.path.dirname(__file__), "../../../..", "logo.png")
if os.path.exists(icon_path):
app.setWindowIcon(QIcon(icon_path))
diff --git a/src/projspec/textapp/main.py b/src/projspec/textapp/main.py
index 2bfcfc7..5911bb4 100644
--- a/src/projspec/textapp/main.py
+++ b/src/projspec/textapp/main.py
@@ -170,6 +170,22 @@ def _basename(url: str) -> str:
return (url.rstrip("/").rsplit("/", 1)[-1]) or url
+def _fmt_age(ts: float) -> str:
+ import datetime
+
+ days = (datetime.datetime.now() - datetime.datetime.fromtimestamp(ts)).days
+ if days == 0:
+ return "today"
+ if days == 1:
+ return "yesterday"
+ if days < 30:
+ return f"{days} days ago"
+ if days < 365:
+ return f"{days // 30} months ago"
+ yrs = days // 365
+ return f"{yrs} year{'s' if yrs > 1 else ''} ago"
+
+
def _is_enum(v: Any) -> bool:
"""True for a serialised enum: ``{klass:['enum', name], value: ...}``."""
return (
@@ -447,20 +463,22 @@ def _accept(self) -> None:
self.dismiss(value)
-class AddPathModal(ModalScreen[str | None]):
- """Ask the user for a directory to add to the library.
+class AddPathModal(ModalScreen[tuple[str, str] | None]):
+ """Ask the user for a directory (or URL / glob pattern) to add to the library.
- A simple text input that defaults to the user's home. Terminal apps
- don't have a native folder picker, so a free-form path is the cleanest
- equivalent of the VSCode ``showOpenDialog``.
+ Returns a ``(path, storage_options)`` tuple, or ``None`` if cancelled.
+ Terminal apps don't have a native folder picker, so a free-form path
+ is the cleanest equivalent of the VSCode ``showOpenDialog``.
"""
DEFAULT_CSS = """
AddPathModal { align: center middle; }
#box {
background: #252526; border: solid #454545;
- padding: 1 2; width: 70; height: auto;
+ padding: 1 2; width: 80; height: auto;
}
+ #hint { color: #858585; margin-bottom: 1; }
+ #so-hint { color: #858585; margin-top: 1; }
#btn-row { margin-top: 1; height: 3; }
#btn-row Button { margin-right: 1; }
"""
@@ -469,8 +487,18 @@ class AddPathModal(ModalScreen[str | None]):
def compose(self) -> ComposeResult:
with Vertical(id="box"):
- yield Label("Add a directory (or URL) to the library:")
+ yield Label(
+ "Path / pattern β a local directory, URL, or glob "
+ "(e.g. ~/projects/* or s3://bucket/prefix):",
+ id="hint",
+ )
yield Input(value=str(Path.home()), id="path-input")
+ yield Label(
+ "Storage options (JSON, optional) β fsspec credentials "
+ 'for remote filesystems, e.g. {"key": "AKIAβ¦", "secret": "β¦"}:',
+ id="so-hint",
+ )
+ yield Input(placeholder='{"key": "β¦", "secret": "β¦"}', id="so-input")
with Horizontal(id="btn-row"):
yield Button("Add", variant="primary", id="btn-ok")
yield Button("Cancel", id="btn-cancel")
@@ -479,7 +507,11 @@ def on_mount(self) -> None:
self.query_one("#path-input", Input).focus()
@on(Input.Submitted, "#path-input")
- def _on_submit(self, event: Input.Submitted) -> None:
+ def _on_path_submit(self, event: Input.Submitted) -> None:
+ self.query_one("#so-input", Input).focus()
+
+ @on(Input.Submitted, "#so-input")
+ def _on_so_submit(self, event: Input.Submitted) -> None:
self._accept()
@on(Button.Pressed, "#btn-ok")
@@ -491,8 +523,12 @@ def _on_cancel(self) -> None:
self.dismiss(None)
def _accept(self) -> None:
- value = self.query_one("#path-input", Input).value.strip()
- self.dismiss(value or None)
+ path = self.query_one("#path-input", Input).value.strip()
+ so = self.query_one("#so-input", Input).value.strip()
+ if path:
+ self.dismiss((path, so))
+ else:
+ self.dismiss(None)
class RevealPickModal(ModalScreen[str | None]):
@@ -635,6 +671,7 @@ class ProjectWidget(Static):
ProjectWidget .title { color: #dcb67a; text-style: bold; }
ProjectWidget .url { color: #858585; }
ProjectWidget .storage { color: #858585; text-style: italic; }
+ ProjectWidget .meta { color: #858585; }
/* Each chips row is a plain horizontal run of ``Chip`` statics laid
out left-to-right. ``#chips-wrap`` stacks multiple such rows when
the total chip width exceeds ``_CHIPS_ROW_WIDTH``. */
@@ -659,6 +696,25 @@ def compose(self) -> ComposeResult:
so = self.project.get("storage_options") or {}
if so:
yield Static(f"storage_options: {json.dumps(so)}", classes="storage")
+ meta_parts = []
+ file_count = self.project.get("file_count")
+ total_size = self.project.get("total_size")
+ if file_count is not None and total_size is not None:
+ from projspec.proj.base import _fmt_size
+
+ meta_parts.append(
+ f"{int(file_count):,} files, {_fmt_size(int(total_size))}"
+ )
+ is_writable = self.project.get("is_writable")
+ if is_writable is not None:
+ meta_parts.append("writable" if is_writable else "read-only")
+ last_modified = self.project.get("last_modified")
+ if last_modified is not None:
+ age = _fmt_age(float(last_modified))
+ by = self.project.get("last_modified_by")
+ meta_parts.append("last modified " + age + (f" by {by}" if by else ""))
+ if meta_parts:
+ yield Static(" Β· ".join(meta_parts), classes="meta")
# Build the full list of chips first, then split into horizontal
# rows that each fit into roughly ``_CHIPS_ROW_WIDTH`` cells. This
# keeps chips visible in narrow library panes (a plain Horizontal
@@ -1004,9 +1060,10 @@ def action_reload(self) -> None:
self._reload()
def action_add(self) -> None:
- def _cb(result: str | None) -> None:
+ def _cb(result: tuple[str, str] | None) -> None:
if result:
- self._scan_and_reload(result, walk=True)
+ path, so = result
+ self._scan_and_reload(path, walk=True, storage_options=so)
self.push_screen(AddPathModal(), _cb)
@@ -1333,11 +1390,14 @@ def _cb(pick: str | None) -> None:
self.push_screen(CreateSpecModal(creatable), _cb)
- def _scan_and_reload(self, url: str, walk: bool) -> None:
+ def _scan_and_reload(self, url: str, walk: bool, storage_options: str = "") -> None:
self._set_busy(True)
try:
+ import json as _json
+
+ so = _json.loads(storage_options) if storage_options.strip() else {}
path = _url_to_local(url)
- proj = projspec.Project(path, walk=walk)
+ proj = projspec.Project(path, walk=walk, storage_options=so)
if walk:
for child_url, child in (proj.children or {}).items():
if child.specs:
diff --git a/src/projspec/utils.py b/src/projspec/utils.py
index 67c3bd2..fa70261 100644
--- a/src/projspec/utils.py
+++ b/src/projspec/utils.py
@@ -84,6 +84,8 @@ def __dir__(self):
def to_dict(obj, compact=True):
"""Make entity into JSON-serialisable dict representation"""
+ if obj is None:
+ return None
if isinstance(obj, dict):
return {
k: (
@@ -451,3 +453,106 @@ def _ipynb_to_py(data: str) -> str:
return "\n\n###\n\n".join(
["".join(_["source"]) for _ in everything["cells"] if _["cell_type"] == "code"]
)
+
+
+def scan_glob(
+ pattern: str,
+ *,
+ types=None,
+ xtypes=None,
+ walk: bool = False,
+ storage_options: str | dict = "",
+ add_to_library: bool = False,
+):
+ """Scan every directory matching *pattern* and yield a ``Project`` for each.
+
+ Parameters
+ ----------
+ pattern:
+ A glob pattern passed directly to :func:`fsspec.AbstractFileSystem.glob`.
+ Works for any fsspec-supported filesystem (local, S3, GCS, β¦).
+ ``~`` is expanded for local paths via :func:`os.path.expanduser`.
+ Non-wildcard paths are accepted too and behave identically to a
+ single-directory scan.
+ types:
+ Spec type names to include (list of str, or ``None`` for all).
+ xtypes:
+ Spec type names to exclude (list of str, or ``None`` for none).
+ walk:
+ If ``True``, each matched directory is also walked for child
+ projects (passed through to :class:`projspec.Project`).
+ storage_options:
+ Storage options for remote filesystems. May be a JSON string or
+ a plain ``dict``; an empty string means no options.
+ add_to_library:
+ If ``True``, each successfully scanned project is added to the
+ default project library via :meth:`projspec.Project.add_to_library`.
+
+ Yields
+ ------
+ projspec.Project
+ One project per matched directory, in the order returned by the
+ glob expansion. Directories that fail to scan are skipped with a
+ logged warning.
+
+ Examples
+ --------
+ >>> for proj in scan_glob("~/projects/*"):
+ ... print(proj.text_summary(bare=True))
+
+ >>> projects = list(scan_glob("s3://my-bucket/workspaces/*",
+ ... storage_options={"anon": False}))
+ """
+ import fsspec
+
+ from projspec.proj import Project
+
+ # Normalise storage_options to a dict
+ if isinstance(storage_options, str):
+ import json
+
+ so: dict = json.loads(storage_options) if storage_options.strip() else {}
+ else:
+ so = dict(storage_options)
+
+ # Obtain the appropriate filesystem for the given pattern.
+ # For local paths, expand ~ before handing off to fsspec.
+ fs, path = fsspec.url_to_fs(pattern, **so)
+ if isinstance(fs, fsspec.implementations.local.LocalFileSystem):
+ path = os.path.expanduser(path)
+
+ candidates = sorted(fs.glob(path))
+
+ # If the pattern contained no wildcards and matched nothing, still try
+ # the literal path so the caller gets a meaningful error rather than silence.
+ if not candidates:
+ candidates = [path]
+
+ for candidate in candidates:
+ try:
+ if fs.info(candidate)["type"] != "directory":
+ continue
+ except FileNotFoundError:
+ logger.warning("Path not found: %s", candidate)
+ continue
+ try:
+ proj = Project(
+ candidate,
+ fs=fs,
+ types=types,
+ xtypes=xtypes,
+ walk=walk,
+ )
+ except Exception:
+ logger.warning("Failed to scan %s", candidate, exc_info=True)
+ continue
+ if not proj:
+ continue
+ if add_to_library:
+ try:
+ proj.add_to_library()
+ except TypeError:
+ import warnings
+
+ warnings.warn(f"{repr(proj)} failed to serialise")
+ yield proj
diff --git a/src/projspec/webui/ipywidget.py b/src/projspec/webui/ipywidget.py
index 2c85c93..07b0892 100644
--- a/src/projspec/webui/ipywidget.py
+++ b/src/projspec/webui/ipywidget.py
@@ -226,7 +226,10 @@ def _on_frontend_message(
elif cmd == "add":
self._offer_add()
elif cmd == "addConfirmed":
- self._add_confirmed(content.get("path", ""))
+ self._add_confirmed(
+ content.get("path", ""),
+ content.get("storageOptions", ""),
+ )
elif cmd == "configure":
_open_config_file(self._toast)
elif cmd == "rescan":
@@ -319,36 +322,34 @@ def _offer_add(self) -> None:
project path."""
self.send({"type": "openAddModal"})
- def _add_confirmed(self, path: str) -> None:
- import os
-
- import projspec
+ def _add_confirmed(self, path: str, storage_options: str = "") -> None:
+ from projspec.utils import scan_glob
path = (path or "").strip()
if not path:
return
- if not os.path.isdir(path):
- self._toast(f"Not a directory: {path}")
- return
self._set_busy(True)
try:
- proj = projspec.Project(path, walk=True)
- # Mirror projspec.Project.add_to_library key format so the
- # library is consistent across UIs (url with fs scheme).
- key = proj.fs.unstrip_protocol(proj.url)
- self._library.add_entry(key, proj)
- # Key walked children by their *own* unstripped URL too. The
- # raw ``proj.children`` dict is keyed by basename (or by
- # relative sub-path for deeper walks), which would not be a
- # usable filesystem location if anyone later tried to re-scan
- # via that key. Using the child's absolute URL keeps every
- # library entry self-describing.
- if proj.children:
+ found = False
+ for proj in scan_glob(
+ path,
+ storage_options=storage_options,
+ walk=True,
+ add_to_library=False,
+ ):
+ found = True
+ key = proj.fs.unstrip_protocol(proj.url)
+ self._library.add_entry(key, proj)
for child in (proj.children or {}).values():
if child.specs:
child_key = child.fs.unstrip_protocol(child.url)
self._library.add_entry(child_key, child)
+ if not found:
+ self._toast(f"No directories found: {path}")
+ return
self._send_initial_data()
+ except Exception as exc:
+ self._toast(f"Scan failed: {exc}")
finally:
self._set_busy(False)
diff --git a/src/projspec/webui/panel.css b/src/projspec/webui/panel.css
index 3c65aee..3740c68 100644
--- a/src/projspec/webui/panel.css
+++ b/src/projspec/webui/panel.css
@@ -45,6 +45,64 @@ body {
font-size: 12px; border-radius: 3px;
}
.toolbar button:hover { background: var(--vscode-button-hoverBackground, #505050); }
+.add-group { position: relative; display: flex; }
+.add-group #btn-add { border-radius: 3px 0 0 3px; }
+.add-group #btn-add-chevron { border-radius: 0 3px 3px 0; padding: 4px 6px; border-left: 1px solid var(--vscode-panel-border, #555); }
+.add-dropdown {
+ position: absolute; top: 100%; left: 0; z-index: 100; min-width: 160px;
+ background: var(--vscode-menu-background, #252526);
+ color: var(--vscode-menu-foreground, #cccccc);
+ border: 1px solid var(--vscode-menu-border, #454545);
+ border-radius: 3px; padding: 4px 0;
+ box-shadow: 0 2px 6px rgba(0,0,0,0.4);
+}
+.add-dropdown .menu-item { padding: 5px 12px; cursor: pointer; font-size: 12px; white-space: nowrap; }
+.add-dropdown .menu-item:hover {
+ background: var(--vscode-list-activeSelectionBackground, #094771);
+ color: var(--vscode-list-activeSelectionForeground, #ffffff);
+}
+#add-advanced-overlay {
+ position: fixed; inset: 0; background: rgba(0,0,0,0.4);
+ display: flex; align-items: center; justify-content: center;
+ z-index: 2000;
+}
+#add-advanced-modal {
+ background: var(--vscode-editorWidget-background, #252526);
+ color: var(--vscode-editorWidget-foreground, var(--vscode-foreground, #cccccc));
+ border: 1px solid var(--vscode-editorWidget-border, var(--vscode-panel-border, #454545));
+ border-radius: 6px; min-width: 420px; max-width: 85%;
+ box-shadow: 0 8px 24px rgba(0,0,0,0.5);
+ display: flex; flex-direction: column;
+}
+#add-advanced-title {
+ padding: 10px 14px; font-weight: bold; font-size: 14px;
+ border-bottom: 1px solid var(--vscode-panel-border, #454545);
+}
+#add-advanced-body { padding: 12px 14px; }
+.add-advanced-hint {
+ font-size: 11px; color: var(--vscode-descriptionForeground, #858585);
+ margin: 0 0 10px 0; line-height: 1.5;
+}
+.add-advanced-hint code {
+ font-family: var(--vscode-editor-font-family, monospace);
+ background: rgba(255,255,255,0.05); padding: 0 3px; border-radius: 2px;
+}
+#add-advanced-body label {
+ display: block; font-size: 12px; margin-bottom: 4px;
+ color: var(--vscode-descriptionForeground, #858585);
+}
+#add-advanced-path, #add-advanced-so {
+ width: 100%; box-sizing: border-box;
+ background: var(--vscode-input-background, #3c3c3c);
+ color: var(--vscode-input-foreground, #cccccc);
+ border: 1px solid var(--vscode-input-border, transparent);
+ padding: 6px 8px; font-size: 13px; border-radius: 3px; outline: none;
+}
+#add-advanced-path:focus, #add-advanced-so:focus { border-color: var(--vscode-focusBorder, #007acc); }
+#add-advanced-actions {
+ display: flex; justify-content: flex-end; gap: 6px;
+ padding: 10px 14px; border-top: 1px solid var(--vscode-panel-border, #454545);
+}
.search {
padding: 6px 8px;
@@ -97,6 +155,10 @@ body {
font-size: 11px; color: var(--vscode-descriptionForeground, #858585);
margin-top: 2px; font-style: italic;
}
+.project .meta {
+ font-size: 11px; color: var(--vscode-descriptionForeground, #858585);
+ margin-top: 2px;
+}
.project .chips { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 6px; }
.chip {
diff --git a/src/projspec/webui/panel.html b/src/projspec/webui/panel.html
index 9d28bd6..692d737 100644
--- a/src/projspec/webui/panel.html
+++ b/src/projspec/webui/panel.html
@@ -10,7 +10,13 @@
-
+
+
+
+
📂 Local browser
+
⚙️ Advanced…
+
+
@@ -36,6 +42,22 @@
+
+
+
Add project — Advanced
+
+
Enter a directory path, URL, or glob pattern (e.g. ~/projects/* or s3://bucket/prefix). Use Storage options to supply credentials or other fsspec options for remote filesystems.
Enter a directory path, URL, or glob pattern (e.g. ~/projects/* or s3://bucket/prefix). Use Storage options to supply credentials or other fsspec options for remote filesystems.