twd-m4sc0 2.0.3__py3-none-any.whl → 3.0.1__py3-none-any.whl
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/__init__.py +6 -0
- twd/cli.py +104 -0
- twd/config.py +76 -0
- twd/data.py +143 -0
- twd/tui.py +217 -0
- twd/utils.py +46 -0
- twd_m4sc0-3.0.1.dist-info/METADATA +14 -0
- twd_m4sc0-3.0.1.dist-info/RECORD +11 -0
- {twd_m4sc0-2.0.3.dist-info → twd_m4sc0-3.0.1.dist-info}/WHEEL +1 -1
- twd_m4sc0-3.0.1.dist-info/entry_points.txt +2 -0
- twd_m4sc0-3.0.1.dist-info/top_level.txt +1 -0
- tests/__init__.py +0 -0
- tests/test_twd.py +0 -20
- twd/__main__.py +0 -4
- twd/crud.py +0 -104
- twd/logger.py +0 -47
- twd/screen.py +0 -201
- twd/twd.py +0 -361
- twd_m4sc0-2.0.3.dist-info/LICENSE +0 -21
- twd_m4sc0-2.0.3.dist-info/METADATA +0 -179
- twd_m4sc0-2.0.3.dist-info/RECORD +0 -14
- twd_m4sc0-2.0.3.dist-info/entry_points.txt +0 -2
- twd_m4sc0-2.0.3.dist-info/top_level.txt +0 -2
twd/__init__.py
CHANGED
twd/cli.py
ADDED
|
@@ -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}")
|
twd/config.py
ADDED
|
@@ -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)
|
twd/data.py
ADDED
|
@@ -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
|
twd/tui.py
ADDED
|
@@ -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)
|
twd/utils.py
ADDED
|
@@ -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
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
twd/__init__.py,sha256=76brvw44gXGskDSsajfwk5rvxoRjk4PT99d4qGUs_64,162
|
|
2
|
+
twd/cli.py,sha256=Shm8ZeHrPKPr-lbo6Mvb_mjWEH8mYBVlpvZ3OcWb2sw,2550
|
|
3
|
+
twd/config.py,sha256=pGAaurguz1Bv6NxjwKbLBbvJ9tuAVJx_l7tcvd0B84I,1996
|
|
4
|
+
twd/data.py,sha256=YPVU2NhZuDOps-pkquRG9y4GW5RHf46eXIm23rgY_Lo,4131
|
|
5
|
+
twd/tui.py,sha256=K48yUMFZbIhGfxzw8-MDaraW7MyakWwVKZJ33GrxzY0,5847
|
|
6
|
+
twd/utils.py,sha256=F42JxIlqUMLsklQ7_zXoIOPmh872E2pHdu1jGLt6-vw,1144
|
|
7
|
+
twd_m4sc0-3.0.1.dist-info/METADATA,sha256=fwhKl3gb_0Eg-UZgzVzFh9ptU-6uxVT2u16I11v7G20,406
|
|
8
|
+
twd_m4sc0-3.0.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
9
|
+
twd_m4sc0-3.0.1.dist-info/entry_points.txt,sha256=KeIDpaQx0GSqgJFYql14wmT3yj_JHTwq7rRsm-mKckc,36
|
|
10
|
+
twd_m4sc0-3.0.1.dist-info/top_level.txt,sha256=B93Li7fIZ4uWoKq8rKVuGPU_MtzyjxxDb0NF96oGZ0o,4
|
|
11
|
+
twd_m4sc0-3.0.1.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
twd
|
tests/__init__.py
DELETED
|
File without changes
|
tests/test_twd.py
DELETED
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
import unittest
|
|
2
|
-
import os
|
|
3
|
-
from twd import twd
|
|
4
|
-
|
|
5
|
-
class TestTWD(unittest.TestCase):
|
|
6
|
-
def test_save_directory(self):
|
|
7
|
-
twd.save_directory()
|
|
8
|
-
self.assertEqual(twd.TWD, os.getcwd())
|
|
9
|
-
|
|
10
|
-
def test_save_specified_directory(self):
|
|
11
|
-
path = "/tmp"
|
|
12
|
-
twd.save_directory(path)
|
|
13
|
-
self.assertEqual(twd.TWD, path)
|
|
14
|
-
|
|
15
|
-
def test_show_directory(self):
|
|
16
|
-
twd.TWD = "/tmp"
|
|
17
|
-
self.assertEqual(twd.TWD, "/tmp")
|
|
18
|
-
|
|
19
|
-
if __name__ == "__main__":
|
|
20
|
-
unittest.main()
|
twd/__main__.py
DELETED