Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Changelog

## 0.1.2 - 2026-04-05

- Moved directory scanning into a subprocess worker for safer cancellation
- Added scan worker guardrails including priority, memory, and timeout controls
- Improved scan error reporting with dialogs and optional `--debug` terminal output
- Disabled the default worker memory cap to avoid false positives on normal scans

## 0.1.1 - 2026-04-05

- Improved scanner responsiveness and stop/cancel behavior
Expand Down
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,12 @@ Print the version:
sview --version
```

Run with scanner debug output in the terminal:

```bash
sview --debug
```

## Configuration

On first launch, `sview` writes a user config file to:
Expand All @@ -40,5 +46,29 @@ On first launch, `sview` writes a user config file to:
~/.config/sview/config.json
```

It also stores lightweight UI state in:

```bash
~/.config/sview/ui_state.json
```

This file controls default file and sequence handlers, including custom
commands for double-click actions.

It also contains scanner worker safety settings such as process priority and
memory limit:

```json
{
"scanner": {
"worker": {
"nice_increment": 15,
"memory_limit_mb": null,
"timeout_seconds": 30
}
}
}
```

`memory_limit_mb` is opt-in. Leave it as `null` unless you specifically want a hard
address-space cap on the worker process.
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "sview"
version = "0.1.1"
version = "0.1.2"
description = "Sequence-aware filesystem browser"
readme = "README.md"
requires-python = ">=3.8"
Expand Down
Binary file modified sview.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion sview/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,4 @@
on usability and a clean interface.
"""

__version__ = "0.1.1"
__version__ = "0.1.2"
108 changes: 104 additions & 4 deletions sview/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@

CONFIG_DIR = Path.home() / ".config" / "sview"
CONFIG_PATH = CONFIG_DIR / "config.json"
UI_STATE_PATH = CONFIG_DIR / "ui_state.json"
DEFAULT_REPOSITORY_URL = "https://github.com/rsgalloway/sview"


Expand All @@ -53,36 +54,61 @@ class HandlerConfig:
command: str = ""


@dataclass
class ScanWorkerConfig:
nice_increment: int = 15
memory_limit_mb: int | None = None
timeout_seconds: int | None = 30


@dataclass
class AppConfig:
file_handler: HandlerConfig
sequence_handler: HandlerConfig
scan_worker: ScanWorkerConfig

@classmethod
def default(cls) -> "AppConfig":
return cls(
file_handler=HandlerConfig(mode="system"),
sequence_handler=HandlerConfig(mode="expand"),
scan_worker=ScanWorkerConfig(),
)

@classmethod
def load(cls) -> "AppConfig":
if not CONFIG_PATH.exists():
config = cls.default()
config.save()
try:
config.save()
except OSError:
pass
return config

try:
data = json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
config = cls.default()
config.save()
try:
config.save()
except OSError:
pass
return config

handlers = data.get("handlers", {})
scanner = data.get("scanner", {})
worker = scanner.get("worker", {})
memory_limit_mb = cls._load_memory_limit(worker)
needs_save = (
"scanner" not in data
or "worker" not in scanner
or "nice_increment" not in worker
or "memory_limit_mb" not in worker
or "timeout_seconds" not in worker
)
file_handler = handlers.get("file", {})
sequence_handler = handlers.get("sequence", {})
return cls(
config = cls(
file_handler=HandlerConfig(
mode=file_handler.get("mode", "system"),
command=file_handler.get("command", ""),
Expand All @@ -91,7 +117,23 @@ def load(cls) -> "AppConfig":
mode=sequence_handler.get("mode", "expand"),
command=sequence_handler.get("command", ""),
),
scan_worker=ScanWorkerConfig(
nice_increment=cls._coerce_int(worker.get("nice_increment"), 15),
memory_limit_mb=memory_limit_mb,
timeout_seconds=(
None
if "timeout_seconds" in worker
and worker.get("timeout_seconds") is None
else cls._coerce_int(worker.get("timeout_seconds"), 30)
),
Comment thread
rsgalloway marked this conversation as resolved.
),
)
if needs_save:
try:
config.save()
except OSError:
pass
return config

def save(self) -> None:
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
Expand All @@ -105,10 +147,53 @@ def save(self) -> None:
"mode": self.sequence_handler.mode,
"command": self.sequence_handler.command,
},
}
},
"scanner": {
"worker": {
"nice_increment": self.scan_worker.nice_increment,
"memory_limit_mb": self.scan_worker.memory_limit_mb,
"timeout_seconds": self.scan_worker.timeout_seconds,
}
},
}
CONFIG_PATH.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")

@staticmethod
def _load_memory_limit(worker: dict[str, object]) -> int | None:
if "memory_limit_mb" not in worker:
return None

value = worker.get("memory_limit_mb")
if value is None:
return None

memory_limit_mb = AppConfig._coerce_optional_int(value, None)
if memory_limit_mb is None:
return None
# Treat the old default memory cap from early 0.1.2 development as "unset"
# because RLIMIT_AS proved too blunt for normal scans.
if memory_limit_mb == 2048:
return None
return memory_limit_mb

@staticmethod
def _coerce_int(value: object, default: int) -> int:
try:
if value is None:
return default
return int(value)
except (TypeError, ValueError):
return default

@staticmethod
def _coerce_optional_int(value: object, default: int | None) -> int | None:
try:
if value is None:
return default
return int(value)
except (TypeError, ValueError):
return default


def build_command(command_template: str, path: str) -> list[str]:
return shlex.split(command_template.format(path=path))
Expand All @@ -128,3 +213,18 @@ def get_repository_url() -> str:
return url.strip()

return DEFAULT_REPOSITORY_URL


def load_ui_state() -> dict[str, object]:
if not UI_STATE_PATH.exists():
return {}
try:
data = json.loads(UI_STATE_PATH.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return {}
return data if isinstance(data, dict) else {}


def save_ui_state(state: dict[str, object]) -> None:
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
UI_STATE_PATH.write_text(json.dumps(state, indent=2) + "\n", encoding="utf-8")
11 changes: 11 additions & 0 deletions sview/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,17 @@ def apply_scan_result(self, result: ScanResult) -> list[BrowserItem]:
self._refresh_current_items()
return self.current_items

def update_sequence_metadata(
self, item_path: str, size_bytes: int, modified_time: float
) -> BrowserItem | None:
for item in self._grouped_items:
if item.path != item_path:
continue
item.size_bytes = size_bytes
item.modified_time = modified_time
return item
return None

def _refresh_current_items(self) -> None:
self._current_items = (
self._grouped_items if self._grouped_view else self._raw_items
Expand Down
39 changes: 39 additions & 0 deletions sview/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,3 +62,42 @@ class BrowserItem:
@property
def missing_count(self) -> int:
return len(self.missing or [])

def to_dict(self) -> dict[str, object]:
return {
"path": self.path,
"item_type": self.item_type.value,
"name": self.name,
"display_name": self.display_name,
"frame_range": self.frame_range,
"pad": self.pad,
"count": self.count,
"missing": list(self.missing) if self.missing is not None else None,
"size_bytes": self.size_bytes,
"modified_time": self.modified_time,
"child_paths": list(self.child_paths)
if self.child_paths is not None
else None,
}

@classmethod
def from_dict(cls, data: dict[str, object]) -> BrowserItem:
return cls(
path=str(data["path"]),
item_type=ItemType(str(data["item_type"])),
name=str(data["name"]),
display_name=str(data["display_name"]),
frame_range=str(data["frame_range"])
if data["frame_range"] is not None
else None,
pad=str(data["pad"]) if data["pad"] is not None else None,
count=int(data["count"]),
missing=[int(value) for value in data["missing"]]
if data["missing"] is not None
else None,
size_bytes=int(data["size_bytes"]),
modified_time=float(data["modified_time"]),
child_paths=[str(value) for value in data["child_paths"]]
if data["child_paths"] is not None
else None,
)
Loading
Loading