twd-m4sc0 2.0.3__tar.gz → 3.0.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- twd_m4sc0-3.0.1/PKG-INFO +14 -0
- twd_m4sc0-3.0.1/README.md +34 -0
- twd_m4sc0-3.0.1/pyproject.toml +31 -0
- twd_m4sc0-3.0.1/src/twd/__init__.py +6 -0
- twd_m4sc0-3.0.1/src/twd/cli.py +104 -0
- twd_m4sc0-3.0.1/src/twd/config.py +76 -0
- twd_m4sc0-3.0.1/src/twd/data.py +143 -0
- twd_m4sc0-3.0.1/src/twd/tui.py +217 -0
- twd_m4sc0-3.0.1/src/twd/utils.py +46 -0
- twd_m4sc0-3.0.1/src/twd_m4sc0.egg-info/PKG-INFO +14 -0
- twd_m4sc0-3.0.1/src/twd_m4sc0.egg-info/SOURCES.txt +14 -0
- twd_m4sc0-3.0.1/src/twd_m4sc0.egg-info/entry_points.txt +2 -0
- twd_m4sc0-3.0.1/src/twd_m4sc0.egg-info/requires.txt +3 -0
- twd_m4sc0-3.0.1/src/twd_m4sc0.egg-info/top_level.txt +1 -0
- twd_m4sc0-2.0.3/LICENSE +0 -21
- twd_m4sc0-2.0.3/PKG-INFO +0 -179
- twd_m4sc0-2.0.3/README.md +0 -166
- twd_m4sc0-2.0.3/setup.py +0 -24
- twd_m4sc0-2.0.3/tests/__init__.py +0 -0
- twd_m4sc0-2.0.3/tests/test_twd.py +0 -20
- twd_m4sc0-2.0.3/twd/__init__.py +0 -0
- twd_m4sc0-2.0.3/twd/__main__.py +0 -4
- twd_m4sc0-2.0.3/twd/crud.py +0 -104
- twd_m4sc0-2.0.3/twd/logger.py +0 -47
- twd_m4sc0-2.0.3/twd/screen.py +0 -201
- twd_m4sc0-2.0.3/twd/twd.py +0 -361
- twd_m4sc0-2.0.3/twd_m4sc0.egg-info/PKG-INFO +0 -179
- twd_m4sc0-2.0.3/twd_m4sc0.egg-info/SOURCES.txt +0 -16
- twd_m4sc0-2.0.3/twd_m4sc0.egg-info/entry_points.txt +0 -2
- twd_m4sc0-2.0.3/twd_m4sc0.egg-info/top_level.txt +0 -2
- {twd_m4sc0-2.0.3 → twd_m4sc0-3.0.1}/setup.cfg +0 -0
- {twd_m4sc0-2.0.3 → twd_m4sc0-3.0.1/src}/twd_m4sc0.egg-info/dependency_links.txt +0 -0
twd_m4sc0-3.0.1/PKG-INFO
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: twd-m4sc0
|
|
3
|
+
Version: 3.0.1
|
|
4
|
+
Summary: TWD by m4sc0
|
|
5
|
+
Author: m4sc0
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/m4sc0/twd
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Requires-Python: >=3.13
|
|
12
|
+
Requires-Dist: click>=8
|
|
13
|
+
Requires-Dist: textual>=7.2
|
|
14
|
+
Requires-Dist: pydantic>=2.12
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# twd-m4sc0
|
|
2
|
+
|
|
3
|
+
> [!IMPORTANT]
|
|
4
|
+
> This is complete rewrite of the program `twd`. If you're using `<=v2.0.3` please make sure to upgrade to the newest major release. The archived version v2 can be viewed [here](https://github.com/m4sc0/twd-archived).
|
|
5
|
+
|
|
6
|
+
> twd-m4sc0 / twd is a command-line tool that allows you to temporarily save a working directory and easily navigate back to it. It's designed for developers and users who frequently need to switch between directories in the terminal.
|
|
7
|
+
|
|
8
|
+
That's what it was supposed to do at the start. Now it's more like a hub for your frequently visited directories. You can use it like a bookmark manager or for the quickest method of changing between directories that you can find.
|
|
9
|
+
|
|
10
|
+
There are quite a few things I wanna make better this time.
|
|
11
|
+
|
|
12
|
+
### Better directory changing
|
|
13
|
+
|
|
14
|
+
Previously twd wrote to a temp file, then a bash function used the contents if that file exists, cd's to the dir and deletes the file again. This time around I'm going a different way. I found the method `os.write(3, path)` which can write to [file descriptors](https://en.wikipedia.org/wiki/File_descriptor) directly. This is the same way stdout and stderr are handled in the background (i think). But i'm using a fourth (0 = stdin, 1 = stdout, 2 = stderr), unused FD to write to. This is then captured in the bash function separately from the stdout.
|
|
15
|
+
|
|
16
|
+
### Improved TUI
|
|
17
|
+
|
|
18
|
+
For some reason I thought writing a whole UI system using [curses](https://de.wikipedia.org/wiki/Curses) was a good idea. Well, now at least I know better and can say that I won't do that ever again.
|
|
19
|
+
|
|
20
|
+
I'll use [Textual](https://textual.textualize.io/) now.
|
|
21
|
+
|
|
22
|
+
### Setup
|
|
23
|
+
|
|
24
|
+
It's possible to use TWD without the feature of cd'ing anywhere. But that's kinda lame lol. To make sure it works as intended, copy the following snippet into something like a `~/.bashrc` file.
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
t () {
|
|
28
|
+
binary="twd"
|
|
29
|
+
local target=$($binary "$@" 3>&1 >/dev/tty)
|
|
30
|
+
if [[ -n "$target" && -d "$target" ]]; then
|
|
31
|
+
cd "$target"
|
|
32
|
+
fi
|
|
33
|
+
}
|
|
34
|
+
```
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "twd-m4sc0"
|
|
3
|
+
version = "3.0.1"
|
|
4
|
+
description = "TWD by m4sc0"
|
|
5
|
+
authors = [{name = "m4sc0"}]
|
|
6
|
+
requires-python = ">=3.13"
|
|
7
|
+
license = {text = "MIT"}
|
|
8
|
+
classifiers = [
|
|
9
|
+
"Programming Language :: Python :: 3",
|
|
10
|
+
"License :: OSI Approved :: MIT License",
|
|
11
|
+
"Operating System :: OS Independent",
|
|
12
|
+
]
|
|
13
|
+
dependencies = [
|
|
14
|
+
"click>=8",
|
|
15
|
+
"textual>=7.2",
|
|
16
|
+
"pydantic>=2.12"
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
[project.urls]
|
|
20
|
+
Homepage = "https://github.com/m4sc0/twd"
|
|
21
|
+
|
|
22
|
+
[project.scripts]
|
|
23
|
+
twd = "twd.cli:cli"
|
|
24
|
+
|
|
25
|
+
[build-system]
|
|
26
|
+
requires = ["setuptools"]
|
|
27
|
+
build-backend = "setuptools.build_meta"
|
|
28
|
+
|
|
29
|
+
[tool.setuptools.packages.find]
|
|
30
|
+
where = ["src"]
|
|
31
|
+
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import click
|
|
2
|
+
import os
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from . import __version__
|
|
5
|
+
from .config import Config
|
|
6
|
+
from .data import TwdManager
|
|
7
|
+
from .tui import TWDApp
|
|
8
|
+
|
|
9
|
+
@click.group(invoke_without_command=True)
|
|
10
|
+
@click.pass_context
|
|
11
|
+
@click.version_option(version=__version__)
|
|
12
|
+
def cli(ctx):
|
|
13
|
+
"""
|
|
14
|
+
TWD - Temp / Tracked Working Directory
|
|
15
|
+
"""
|
|
16
|
+
ctx.ensure_object(dict)
|
|
17
|
+
|
|
18
|
+
ctx.obj['config'] = Config.load() # load config
|
|
19
|
+
ctx.obj['manager'] = TwdManager(ctx.obj['config'].data_path)
|
|
20
|
+
|
|
21
|
+
# if no subcommand was provided, launch TUI
|
|
22
|
+
if ctx.invoked_subcommand is None:
|
|
23
|
+
# TODO: launch TUI here
|
|
24
|
+
|
|
25
|
+
path = TWDApp(manager=ctx.obj['manager']).run()
|
|
26
|
+
|
|
27
|
+
# write to fd3
|
|
28
|
+
os.write(3, bytes(str(path), "utf-8"))
|
|
29
|
+
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
@cli.command()
|
|
33
|
+
@click.argument('path')
|
|
34
|
+
@click.argument('alias', required=False)
|
|
35
|
+
@click.argument('name', required=False)
|
|
36
|
+
@click.pass_context
|
|
37
|
+
def save(ctx, path, alias, name):
|
|
38
|
+
"""
|
|
39
|
+
Save a new twd with PATH, [opt] ALIAS and [opt] NAME
|
|
40
|
+
"""
|
|
41
|
+
manager: TwdManager = ctx.obj['manager']
|
|
42
|
+
|
|
43
|
+
path_obj = Path(path).expanduser().resolve()
|
|
44
|
+
|
|
45
|
+
# get alias from path
|
|
46
|
+
if alias is None:
|
|
47
|
+
alias = path_obj.name.lower().replace(" ", "_")
|
|
48
|
+
|
|
49
|
+
try:
|
|
50
|
+
entry = manager.add(alias, path_obj, name)
|
|
51
|
+
click.echo(f"Saved '{entry.alias}' -> {entry.path}")
|
|
52
|
+
except ValueError as e:
|
|
53
|
+
click.echo(f"Error: {e}")
|
|
54
|
+
raise click.Abort()
|
|
55
|
+
|
|
56
|
+
@cli.command()
|
|
57
|
+
@click.argument('alias')
|
|
58
|
+
@click.pass_context
|
|
59
|
+
def get(ctx, alias):
|
|
60
|
+
"""
|
|
61
|
+
get path for ALIAS
|
|
62
|
+
"""
|
|
63
|
+
manager = ctx.obj['manager']
|
|
64
|
+
|
|
65
|
+
entry = manager.get(alias)
|
|
66
|
+
|
|
67
|
+
if entry is None:
|
|
68
|
+
click.echo(f"Alias '{alias}' not found")
|
|
69
|
+
raise click.Abort()
|
|
70
|
+
|
|
71
|
+
# TODO: os.write(3) / write to fd3 for shell integration
|
|
72
|
+
os.write(3, bytes(str(entry.path), "utf-8"))
|
|
73
|
+
|
|
74
|
+
click.echo(f"cd-ing to {entry.path}")
|
|
75
|
+
|
|
76
|
+
@cli.command()
|
|
77
|
+
@click.argument('alias')
|
|
78
|
+
@click.pass_context
|
|
79
|
+
def remove(ctx, alias):
|
|
80
|
+
"""
|
|
81
|
+
remove TWD by ALIAS
|
|
82
|
+
"""
|
|
83
|
+
manager = ctx.obj['manager']
|
|
84
|
+
|
|
85
|
+
try:
|
|
86
|
+
manager.remove(alias)
|
|
87
|
+
click.echo(f"Removed '{alias}'")
|
|
88
|
+
except KeyError as e:
|
|
89
|
+
click.echo(f"{e}", err=True)
|
|
90
|
+
raise click.Abort()
|
|
91
|
+
|
|
92
|
+
@cli.command('list')
|
|
93
|
+
@click.pass_context
|
|
94
|
+
def list_twds(ctx):
|
|
95
|
+
"""List all TWDs"""
|
|
96
|
+
manager = ctx.obj['manager']
|
|
97
|
+
|
|
98
|
+
entries = manager.list_all()
|
|
99
|
+
if not entries:
|
|
100
|
+
click.echo("No TWDs saved yet. Use 'twd save <path> <alias>' to add one.")
|
|
101
|
+
return
|
|
102
|
+
|
|
103
|
+
for entry in entries:
|
|
104
|
+
click.echo(f"{entry.alias:20} {entry.name:30} {entry.path}")
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from pydantic import BaseModel, Field, validator
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
def get_config_path() -> Path:
|
|
10
|
+
"""Cross-platform config location"""
|
|
11
|
+
|
|
12
|
+
if sys.platform == "win32":
|
|
13
|
+
base = Path(os.getenv("APPDATA", Path.home() / "AppData" / "Local"))
|
|
14
|
+
else:
|
|
15
|
+
base = Path(os.getenv("XDG_CONFIG_HOME", Path.home() / ".config"))
|
|
16
|
+
|
|
17
|
+
config_dir = base / "twd"
|
|
18
|
+
config_dir.mkdir(parents=True, exist_ok=True)
|
|
19
|
+
|
|
20
|
+
return config_dir / "config.json"
|
|
21
|
+
|
|
22
|
+
def get_data_path() -> Path:
|
|
23
|
+
"""Cross-platform data location"""
|
|
24
|
+
|
|
25
|
+
if sys.platform == "win32":
|
|
26
|
+
base = Path(os.getenv("APPDATA", Path.home() / "AppData" / "Local"))
|
|
27
|
+
else:
|
|
28
|
+
base = Path(os.getenv("XDG_DATA_HOME", Path.home() / ".local" / "share"))
|
|
29
|
+
|
|
30
|
+
data_dir = base / "twd"
|
|
31
|
+
data_dir.mkdir(parents=True, exist_ok=True)
|
|
32
|
+
|
|
33
|
+
return data_dir / "data.csv"
|
|
34
|
+
|
|
35
|
+
class Config(BaseModel):
|
|
36
|
+
"""App configuration"""
|
|
37
|
+
|
|
38
|
+
data_path: Path = Field(default_factory=get_data_path)
|
|
39
|
+
|
|
40
|
+
@validator("data_path")
|
|
41
|
+
def validate_path(cls, v):
|
|
42
|
+
"""Expand home and ensure path exists"""
|
|
43
|
+
|
|
44
|
+
path = Path(v).expanduser()
|
|
45
|
+
if not path.exists():
|
|
46
|
+
path = get_data_path()
|
|
47
|
+
return path
|
|
48
|
+
|
|
49
|
+
@classmethod
|
|
50
|
+
def load(cls) -> "Config":
|
|
51
|
+
"""Load config from file, create with defaults"""
|
|
52
|
+
config_file = get_config_path()
|
|
53
|
+
|
|
54
|
+
if not config_file.exists():
|
|
55
|
+
config = cls()
|
|
56
|
+
config.save()
|
|
57
|
+
return config
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
with open(config_file, "rb") as f:
|
|
61
|
+
data = json.load(f)
|
|
62
|
+
|
|
63
|
+
return cls(**data)
|
|
64
|
+
except Exception as e:
|
|
65
|
+
print(f"Warning: config file corrupted, using defaults: {e}")
|
|
66
|
+
return cls()
|
|
67
|
+
|
|
68
|
+
def save(self) -> None:
|
|
69
|
+
"""save config to file"""
|
|
70
|
+
|
|
71
|
+
config_file = get_config_path()
|
|
72
|
+
|
|
73
|
+
data = self.model_dump(mode="json")
|
|
74
|
+
|
|
75
|
+
with open(config_file, 'w') as f:
|
|
76
|
+
json.dump(data, f, indent=2)
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import csv
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from pydantic import BaseModel, Field, validator
|
|
5
|
+
from typing import List, Optional
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
|
|
8
|
+
from .config import Config
|
|
9
|
+
from .utils import search
|
|
10
|
+
|
|
11
|
+
class Entry(BaseModel):
|
|
12
|
+
"""Data class for a signle TWD Entry"""
|
|
13
|
+
|
|
14
|
+
alias: str = Field(..., min_length=2, max_length=64)
|
|
15
|
+
path: Path
|
|
16
|
+
name: str = Field(..., min_length=3)
|
|
17
|
+
created_at: datetime = Field(default_factory=datetime.now)
|
|
18
|
+
|
|
19
|
+
@validator("alias")
|
|
20
|
+
def validate_alias(cls, v):
|
|
21
|
+
if not v.replace("_", "").replace("-", "").isalnum():
|
|
22
|
+
raise ValueError("Alias must be alphanumeric with - or _")
|
|
23
|
+
|
|
24
|
+
return v.lower()
|
|
25
|
+
|
|
26
|
+
@validator("path")
|
|
27
|
+
def validate_path(cls, v):
|
|
28
|
+
path = Path(v).expanduser().resolve()
|
|
29
|
+
|
|
30
|
+
if not path.exists():
|
|
31
|
+
raise ValueError(f"Path does not exist: {path}")
|
|
32
|
+
if not path.is_dir():
|
|
33
|
+
raise ValueError(f"Path is not a directory: {path}")
|
|
34
|
+
return path
|
|
35
|
+
|
|
36
|
+
def to_csv(self) -> List[str]:
|
|
37
|
+
"""Convert to csv row"""
|
|
38
|
+
return [
|
|
39
|
+
self.alias,
|
|
40
|
+
str(self.path),
|
|
41
|
+
self.name,
|
|
42
|
+
self.created_at.isoformat()
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
@classmethod
|
|
46
|
+
def from_csv(cls, row: List[str]) -> "Entry":
|
|
47
|
+
"""create from csv row"""
|
|
48
|
+
return cls(
|
|
49
|
+
alias=row[0],
|
|
50
|
+
path=Path(row[1]),
|
|
51
|
+
name=row[2],
|
|
52
|
+
created_at=datetime.fromisoformat(row[3])
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
class TwdManager:
|
|
56
|
+
"""twd entry manager stored in csv"""
|
|
57
|
+
|
|
58
|
+
CSV_HEADERS = ["alias", "path", "name", "created_at"]
|
|
59
|
+
|
|
60
|
+
def __init__(self, csv_path: Path):
|
|
61
|
+
self.csv_path = csv_path
|
|
62
|
+
self._ensure_csv_exists()
|
|
63
|
+
self.cwd = str(Path.cwd())
|
|
64
|
+
|
|
65
|
+
def _ensure_csv_exists(self) -> None:
|
|
66
|
+
"""create csv headers"""
|
|
67
|
+
if not self.csv_path.exists():
|
|
68
|
+
self.csv_path.parent.mkdir(parents=True, exist_ok=True)
|
|
69
|
+
|
|
70
|
+
with open(self.csv_path, 'w', newline='') as f:
|
|
71
|
+
writer = csv.writer(f)
|
|
72
|
+
writer.writerow(self.CSV_HEADERS)
|
|
73
|
+
|
|
74
|
+
def _read_all(self) -> List[Entry]:
|
|
75
|
+
"""read all entries"""
|
|
76
|
+
entries = []
|
|
77
|
+
|
|
78
|
+
with open(self.csv_path, 'r', newline='') as f:
|
|
79
|
+
reader = csv.reader(f)
|
|
80
|
+
|
|
81
|
+
next(reader) # skip headers
|
|
82
|
+
|
|
83
|
+
for row in reader:
|
|
84
|
+
try:
|
|
85
|
+
entries.append(Entry.from_csv(row))
|
|
86
|
+
except Exception as e:
|
|
87
|
+
print(f"Warning: skipping invalid row: {e}")
|
|
88
|
+
|
|
89
|
+
return entries
|
|
90
|
+
|
|
91
|
+
def _write_all(self, entries: List[Entry]) -> None:
|
|
92
|
+
with open(self.csv_path, 'w', newline='') as f:
|
|
93
|
+
writer = csv.writer(f)
|
|
94
|
+
writer.writerow(self.CSV_HEADERS)
|
|
95
|
+
|
|
96
|
+
for entry in entries:
|
|
97
|
+
writer.writerow(entry.to_csv())
|
|
98
|
+
|
|
99
|
+
def add(self, alias: str, path: Path, name: Optional[str] = None) -> Entry:
|
|
100
|
+
"""Add new entry"""
|
|
101
|
+
entries = self._read_all()
|
|
102
|
+
|
|
103
|
+
if any(e.alias == alias.lower() for e in entries):
|
|
104
|
+
raise ValueError(f"Alias '{alias}' already exists")
|
|
105
|
+
|
|
106
|
+
if name is None:
|
|
107
|
+
name = Path(path).name
|
|
108
|
+
|
|
109
|
+
entry = Entry(alias=alias, path=path, name=name)
|
|
110
|
+
entries.append(entry)
|
|
111
|
+
self._write_all(entries)
|
|
112
|
+
|
|
113
|
+
return entry
|
|
114
|
+
|
|
115
|
+
def get(self, alias: str) -> Optional[Entry]:
|
|
116
|
+
"""get entry by alias"""
|
|
117
|
+
entries = self._read_all()
|
|
118
|
+
|
|
119
|
+
for entry in entries:
|
|
120
|
+
if entry.alias == alias.lower():
|
|
121
|
+
return entry
|
|
122
|
+
|
|
123
|
+
return None
|
|
124
|
+
|
|
125
|
+
def remove(self, alias: str) -> None:
|
|
126
|
+
"""remove entry by alias"""
|
|
127
|
+
entries = self._read_all()
|
|
128
|
+
original_len = len(entries)
|
|
129
|
+
|
|
130
|
+
entries = [e for e in entries if e.alias != alias.lower()]
|
|
131
|
+
|
|
132
|
+
if len(entries) == original_len:
|
|
133
|
+
raise KeyError(f"Alias '{alias}' not found")
|
|
134
|
+
|
|
135
|
+
self._write_all(entries)
|
|
136
|
+
|
|
137
|
+
def list_all(self) -> List[Entry]:
|
|
138
|
+
entries = self._read_all()
|
|
139
|
+
|
|
140
|
+
return sorted(entries, key=lambda e: e.created_at)
|
|
141
|
+
|
|
142
|
+
def exists(self, alias: str) -> bool:
|
|
143
|
+
return self.get(alias) is not None
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
from enum import Enum
|
|
2
|
+
from textual import on
|
|
3
|
+
from textual.app import App, ComposeResult, Binding
|
|
4
|
+
from textual.containers import HorizontalGroup, VerticalScroll
|
|
5
|
+
from textual.reactive import reactive
|
|
6
|
+
from textual.widgets import Button, Digits, Footer, Header, DataTable, Label, Rule, Input
|
|
7
|
+
from textual.color import Color
|
|
8
|
+
|
|
9
|
+
from twd.config import Config
|
|
10
|
+
from twd.data import TwdManager
|
|
11
|
+
from twd.utils import search, linear_search
|
|
12
|
+
|
|
13
|
+
class Mode(Enum):
|
|
14
|
+
NORMAL = "normal"
|
|
15
|
+
SEARCH = "search"
|
|
16
|
+
|
|
17
|
+
class TWDApp(App):
|
|
18
|
+
"""
|
|
19
|
+
TWD TUI Application
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
CSS_PATH = "tui.tcss"
|
|
23
|
+
|
|
24
|
+
BINDINGS = [
|
|
25
|
+
# motion
|
|
26
|
+
Binding("j", "cursor_down", "Down"),
|
|
27
|
+
Binding("k", "cursor_up", "Up"),
|
|
28
|
+
|
|
29
|
+
# modify
|
|
30
|
+
Binding("/", "enter_search_mode", "Search"),
|
|
31
|
+
Binding("escape", "enter_normal_mode", "Normal", show=False),
|
|
32
|
+
# TODO: edit
|
|
33
|
+
# TODO: rename
|
|
34
|
+
|
|
35
|
+
# select
|
|
36
|
+
Binding("enter", "select", "Select"),
|
|
37
|
+
|
|
38
|
+
# exit
|
|
39
|
+
Binding("q", "exit", "Exit"),
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
mode: Mode = reactive(Mode.NORMAL)
|
|
43
|
+
|
|
44
|
+
def __init__(self, manager: TwdManager, *args, **kwargs):
|
|
45
|
+
self.manager = manager
|
|
46
|
+
super().__init__(*args, **kwargs)
|
|
47
|
+
|
|
48
|
+
def compose(self) -> ComposeResult:
|
|
49
|
+
yield Header(show_clock=True)
|
|
50
|
+
yield Footer()
|
|
51
|
+
|
|
52
|
+
# cwd
|
|
53
|
+
yield Label(f"cwd: {self.manager.cwd}", classes="cwd")
|
|
54
|
+
|
|
55
|
+
yield Input(placeholder="Search...", id="search-input")
|
|
56
|
+
|
|
57
|
+
# twd selection table
|
|
58
|
+
yield DataTable(
|
|
59
|
+
cursor_type='row',
|
|
60
|
+
cell_padding=2,
|
|
61
|
+
# zebra_stripes=True,
|
|
62
|
+
id="data",
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
def on_mount(self) -> None:
|
|
66
|
+
# app config
|
|
67
|
+
self.theme = "flexoki"
|
|
68
|
+
self.title = "TWD"
|
|
69
|
+
self.sub_title = "Tracked Working Directory"
|
|
70
|
+
|
|
71
|
+
search_input = self.query_one("#search-input", Input)
|
|
72
|
+
search_input.display = False
|
|
73
|
+
|
|
74
|
+
self._populate_table()
|
|
75
|
+
|
|
76
|
+
def _populate_table(self, entries=None) -> None:
|
|
77
|
+
"""
|
|
78
|
+
fill or refresh data table
|
|
79
|
+
"""
|
|
80
|
+
table = self.query_one(DataTable)
|
|
81
|
+
table.clear(columns=True)
|
|
82
|
+
|
|
83
|
+
# add headers
|
|
84
|
+
table.add_columns(*self.manager.CSV_HEADERS)
|
|
85
|
+
|
|
86
|
+
if entries is None:
|
|
87
|
+
entries = self.manager.list_all()
|
|
88
|
+
|
|
89
|
+
# fill data
|
|
90
|
+
for entry in entries:
|
|
91
|
+
table.add_row(entry.alias, str(entry.path), entry.name, entry.created_at)
|
|
92
|
+
|
|
93
|
+
def watch_mode(self, old_mode: Mode, new_mode: Mode) -> None:
|
|
94
|
+
"""
|
|
95
|
+
react to mode changes
|
|
96
|
+
"""
|
|
97
|
+
search_input = self.query_one("#search-input", Input)
|
|
98
|
+
table = self.query_one(DataTable)
|
|
99
|
+
|
|
100
|
+
if new_mode == Mode.SEARCH:
|
|
101
|
+
# enter search mode
|
|
102
|
+
search_input.display = True
|
|
103
|
+
search_input.value = ""
|
|
104
|
+
search_input.focus()
|
|
105
|
+
self.sub_title = "Tracked Working Directory — SEARCH"
|
|
106
|
+
elif new_mode == Mode.NORMAL:
|
|
107
|
+
# enter normal mode
|
|
108
|
+
search_input.display = False
|
|
109
|
+
search_input.value = ""
|
|
110
|
+
self._populate_table()
|
|
111
|
+
table.focus()
|
|
112
|
+
self.sub_title = "Tracked Working Directory"
|
|
113
|
+
|
|
114
|
+
# actions
|
|
115
|
+
def action_cursor_down(self) -> None:
|
|
116
|
+
"""
|
|
117
|
+
move cursor down
|
|
118
|
+
"""
|
|
119
|
+
table = self.query_one(DataTable)
|
|
120
|
+
|
|
121
|
+
current_row = table.cursor_coordinate.row
|
|
122
|
+
next_row = (current_row + 1) % table.row_count
|
|
123
|
+
|
|
124
|
+
table.move_cursor(row=next_row)
|
|
125
|
+
|
|
126
|
+
def action_cursor_up(self) -> None:
|
|
127
|
+
"""
|
|
128
|
+
move cursor up
|
|
129
|
+
"""
|
|
130
|
+
table = self.query_one(DataTable)
|
|
131
|
+
|
|
132
|
+
current_row = table.cursor_coordinate.row
|
|
133
|
+
prev_row = (current_row - 1) % table.row_count
|
|
134
|
+
|
|
135
|
+
table.move_cursor(row=prev_row)
|
|
136
|
+
|
|
137
|
+
def action_enter_search_mode(self) -> None:
|
|
138
|
+
"""
|
|
139
|
+
enter search mode
|
|
140
|
+
"""
|
|
141
|
+
if self.mode == Mode.SEARCH:
|
|
142
|
+
return
|
|
143
|
+
self.mode = Mode.SEARCH
|
|
144
|
+
|
|
145
|
+
def action_enter_normal_mode(self) -> None:
|
|
146
|
+
"""
|
|
147
|
+
enter normal mode
|
|
148
|
+
"""
|
|
149
|
+
if self.mode == Mode.NORMAL:
|
|
150
|
+
return
|
|
151
|
+
self.mode = Mode.NORMAL
|
|
152
|
+
|
|
153
|
+
def action_exit(self) -> None:
|
|
154
|
+
self.exit()
|
|
155
|
+
|
|
156
|
+
@on(Input.Changed, "#search-input")
|
|
157
|
+
def on_search_input_changed(self, e: Input.Changed) -> None:
|
|
158
|
+
"""
|
|
159
|
+
filter table as user types
|
|
160
|
+
"""
|
|
161
|
+
if self.mode != Mode.SEARCH:
|
|
162
|
+
return
|
|
163
|
+
|
|
164
|
+
query = e.value
|
|
165
|
+
|
|
166
|
+
all_entries = self.manager.list_all()
|
|
167
|
+
|
|
168
|
+
# TODO: filter entries and repopulate table
|
|
169
|
+
|
|
170
|
+
search_result = linear_search(query, all_entries)
|
|
171
|
+
|
|
172
|
+
filtered = [entry for entry in all_entries if entry.alias in search_result]
|
|
173
|
+
|
|
174
|
+
self._populate_table(filtered)
|
|
175
|
+
|
|
176
|
+
@on(Input.Submitted, "#search-input")
|
|
177
|
+
def on_search_submitted(self, e: Input.Submitted) -> None:
|
|
178
|
+
"""
|
|
179
|
+
when user presses enter in search, return to normal mode
|
|
180
|
+
"""
|
|
181
|
+
if self.mode != Mode.SEARCH:
|
|
182
|
+
return
|
|
183
|
+
|
|
184
|
+
self.mode = Mode.NORMAL
|
|
185
|
+
|
|
186
|
+
self.query_one(DataTable).focus()
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@on(DataTable.RowSelected)
|
|
190
|
+
def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
|
|
191
|
+
"""
|
|
192
|
+
select row and send back to the original program (probably cli)
|
|
193
|
+
"""
|
|
194
|
+
table = event.data_table
|
|
195
|
+
row_key = event.row_key
|
|
196
|
+
|
|
197
|
+
# get row
|
|
198
|
+
row_data = table.get_row(row_key)
|
|
199
|
+
alias = row_data[0]
|
|
200
|
+
|
|
201
|
+
# get entry
|
|
202
|
+
entry = self.manager.get(alias)
|
|
203
|
+
|
|
204
|
+
self.notify(f"Selected: {entry.alias} -> {entry.path}")
|
|
205
|
+
|
|
206
|
+
# return selected path to cli
|
|
207
|
+
self.exit(entry.path)
|
|
208
|
+
|
|
209
|
+
if __name__ == "__main__":
|
|
210
|
+
# made sure it works with 'serve'
|
|
211
|
+
config = Config.load()
|
|
212
|
+
manager = TwdManager(config.data_path)
|
|
213
|
+
|
|
214
|
+
app = TWDApp(manager=manager)
|
|
215
|
+
path = app.run()
|
|
216
|
+
|
|
217
|
+
print(path)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from rapidfuzz import fuzz
|
|
2
|
+
from typing import List
|
|
3
|
+
|
|
4
|
+
FUZZY_THRESHOLD = 50
|
|
5
|
+
|
|
6
|
+
def normalize(name) -> str:
|
|
7
|
+
return name.lower().replace('-', ' ').replace('_', ' ')
|
|
8
|
+
|
|
9
|
+
def search(query, items, threshold = 50) -> List:
|
|
10
|
+
"""
|
|
11
|
+
query: search input
|
|
12
|
+
items: list of (alias, name)
|
|
13
|
+
|
|
14
|
+
returns: list of (alias, name, score)
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
# return all items if query is empty
|
|
18
|
+
if not query:
|
|
19
|
+
return [(alias, name, 100) for alias, name in items]
|
|
20
|
+
|
|
21
|
+
normalized_query = normalize(query)
|
|
22
|
+
results = []
|
|
23
|
+
|
|
24
|
+
# filtering
|
|
25
|
+
for alias, name in items:
|
|
26
|
+
alias_score = fuzz.WRatio(normalized_query, normalize(alias))
|
|
27
|
+
name_score = fuzz.WRatio(normalized_query, normalize(name))
|
|
28
|
+
|
|
29
|
+
# choose higher score
|
|
30
|
+
best_score = max(alias_score, name_score)
|
|
31
|
+
|
|
32
|
+
# filter out low scores
|
|
33
|
+
if best_score >= threshold:
|
|
34
|
+
results.append((alias, name, best_score))
|
|
35
|
+
|
|
36
|
+
results.sort(key=lambda x: x[2], reverse=True)
|
|
37
|
+
|
|
38
|
+
return results
|
|
39
|
+
|
|
40
|
+
def linear_search(query, items) -> List:
|
|
41
|
+
"""
|
|
42
|
+
simple substring search
|
|
43
|
+
"""
|
|
44
|
+
result = [entry.alias for entry in items if query in entry.alias]
|
|
45
|
+
|
|
46
|
+
return result
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: twd-m4sc0
|
|
3
|
+
Version: 3.0.1
|
|
4
|
+
Summary: TWD by m4sc0
|
|
5
|
+
Author: m4sc0
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/m4sc0/twd
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Requires-Python: >=3.13
|
|
12
|
+
Requires-Dist: click>=8
|
|
13
|
+
Requires-Dist: textual>=7.2
|
|
14
|
+
Requires-Dist: pydantic>=2.12
|