twd-m4sc0 3.0.4__tar.gz → 3.1.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: twd-m4sc0
3
- Version: 3.0.4
3
+ Version: 3.1.1
4
4
  Summary: TWD by m4sc0
5
5
  Author: m4sc0
6
6
  License: MIT
@@ -1,6 +1,7 @@
1
1
  [project]
2
2
  name = "twd-m4sc0"
3
- version = "3.0.4"
3
+ # version = "3.1.0"
4
+ dynamic = ["version"]
4
5
  description = "TWD by m4sc0"
5
6
  authors = [{name = "m4sc0"}]
6
7
  requires-python = ">=3.13"
@@ -32,3 +33,6 @@ where = ["src"]
32
33
 
33
34
  [tool.setuptools.package-data]
34
35
  twd = ["*.tcss"]
36
+
37
+ [tool.setuptools.dynamic]
38
+ version = {attr = "twd.__version__"}
@@ -0,0 +1 @@
1
+ __version__ = "3.1.1"
@@ -8,7 +8,7 @@ from .tui import TWDApp
8
8
 
9
9
  @click.group(invoke_without_command=True)
10
10
  @click.pass_context
11
- @click.version_option(version=__version__)
11
+ @click.version_option(version=__version__, prog_name="twd")
12
12
  def cli(ctx):
13
13
  """
14
14
  TWD - Temp / Tracked Working Directory
@@ -39,7 +39,18 @@ def cli(ctx):
39
39
  @click.pass_context
40
40
  def save(ctx, path, alias, name):
41
41
  """
42
- Save a new twd with PATH, [opt] ALIAS and [opt] NAME
42
+ Save a new TWD with an Alias (UUID like) and Name
43
+
44
+ Examples:
45
+
46
+ twd save ./Movies movies Media
47
+
48
+ twd save / root "Root Directory -- /dev/sda3"
49
+
50
+ twd save . finances-q1 "Finance Documents Q1"
51
+
52
+ The naming conventions for the alias align with kebab-casing.
53
+ There are no naming conventions for the name.
43
54
  """
44
55
  manager: TwdManager = ctx.obj['manager']
45
56
 
@@ -61,7 +72,13 @@ def save(ctx, path, alias, name):
61
72
  @click.pass_context
62
73
  def get(ctx, alias):
63
74
  """
64
- get path for ALIAS
75
+ cd into a directory by the given Alias
76
+
77
+ Examples:
78
+
79
+ twd get movies
80
+
81
+ There's really not much to it.
65
82
  """
66
83
  manager = ctx.obj['manager']
67
84
 
@@ -81,7 +98,13 @@ def get(ctx, alias):
81
98
  @click.pass_context
82
99
  def remove(ctx, alias):
83
100
  """
84
- remove TWD by ALIAS
101
+ Remove TWD by the given Alias
102
+
103
+ Examples:
104
+
105
+ twd remove movies
106
+
107
+ That's it.
85
108
  """
86
109
  manager = ctx.obj['manager']
87
110
 
@@ -95,7 +118,9 @@ def remove(ctx, alias):
95
118
  @cli.command('list')
96
119
  @click.pass_context
97
120
  def list_twds(ctx):
98
- """List all TWDs"""
121
+ """
122
+ List all TWDs
123
+ """
99
124
  manager = ctx.obj['manager']
100
125
 
101
126
  entries = manager.list_all()
@@ -103,5 +128,65 @@ def list_twds(ctx):
103
128
  click.echo("No TWDs saved yet. Use 'twd save <path> <alias>' to add one.")
104
129
  return
105
130
 
131
+ invalid_found = False
106
132
  for entry in entries:
133
+ if not invalid_found and not os.path.exists(entry.path):
134
+ invalid_found = True
107
135
  click.echo(f"{entry.alias:20} {entry.name:30} {entry.path}")
136
+
137
+ if invalid_found:
138
+ click.echo(f"\nInvalid TWD paths found. Run <twd clean> to remove them.")
139
+
140
+
141
+ @cli.command('clean')
142
+ @click.option('--yes', '-y', is_flag=True, help="Remove all invalid entries without asking")
143
+ @click.pass_context
144
+ def clean(ctx, yes):
145
+ """
146
+ Clean the records of rogue TWDs
147
+
148
+ Sometimes it's possible that TWDs are not properly cleaned up.
149
+ That's what this subcommand does.
150
+ """
151
+ manager = ctx.obj['manager']
152
+
153
+ # all entries, valid and invalid
154
+ entries = manager.list_all()
155
+
156
+ # only invalids
157
+ invalids = []
158
+ for entry in entries:
159
+ if os.path.exists(entry.path):
160
+ continue
161
+
162
+ invalids.append(entry)
163
+
164
+ if len(invalids) == 0:
165
+ click.echo("No invalid TWDs found.")
166
+ return
167
+
168
+ click.echo(f"Found {len(invalids)} invalid TWDs\n")
169
+
170
+ # only valid
171
+ valid_entries = []
172
+ for entry in entries:
173
+ if entry not in invalids:
174
+ valid_entries.append(entry)
175
+
176
+ # remove all
177
+ if yes:
178
+ click.echo("Removing all invalid entries...")
179
+ manager._write_all(valid_entries)
180
+ click.echo(f"Done. {len(valid_entries)} TWDs left.")
181
+ return
182
+
183
+ to_keep = []
184
+ for inv in invalids:
185
+ if not click.confirm(f"Do you want to remove '{inv.alias}'?", default=True):
186
+ # user wants to keep invalid TWD (weird but i'll allow it)
187
+ to_keep.append(inv)
188
+
189
+ # write a unison list of valid entries and the ones to keep
190
+ final_entries = valid_entries + to_keep
191
+ manager._write_all(final_entries)
192
+ click.echo(f"Done. {len(final_entries)} TWDs left.")
@@ -15,6 +15,17 @@ class Entry(BaseModel):
15
15
  name: str = Field(..., min_length=3)
16
16
  created_at: datetime = Field(default_factory=datetime.now)
17
17
 
18
+ def __eq__(self, other) -> bool:
19
+ """Compare entries based on their values"""
20
+ if not isinstance(other, Entry):
21
+ return NotImplemented
22
+
23
+ return (
24
+ self.alias == other.alias
25
+ and self.path == other.path
26
+ and self.name == other.name
27
+ )
28
+
18
29
  @validator("alias")
19
30
  def validate_alias(cls, v):
20
31
  if not v.replace("_", "").replace("-", "").isalnum():
@@ -26,10 +37,6 @@ class Entry(BaseModel):
26
37
  def validate_path(cls, v):
27
38
  path = Path(v).expanduser().resolve()
28
39
 
29
- if not path.exists():
30
- raise ValueError(f"Path does not exist: {path}")
31
- if not path.is_dir():
32
- raise ValueError(f"Path is not a directory: {path}")
33
40
  return path
34
41
 
35
42
  def to_csv(self) -> List[str]:
@@ -51,10 +58,21 @@ class Entry(BaseModel):
51
58
  created_at=datetime.fromisoformat(row[3])
52
59
  )
53
60
 
61
+ @classmethod
62
+ def from_values(cls, alias, path, name, created_at) -> "Entry":
63
+ """create from values"""
64
+ return cls(
65
+ alias=alias,
66
+ path=path,
67
+ name=name,
68
+ created_at=created_at
69
+ )
70
+
54
71
  class TwdManager:
55
72
  """twd entry manager stored in csv"""
56
73
 
57
74
  CSV_HEADERS = ["alias", "path", "name", "created_at"]
75
+ CSV_HEADERS_FANCY = ["Alias", "Path", "Description", "Created at"]
58
76
 
59
77
  def __init__(self, csv_path: Path):
60
78
  self.csv_path = csv_path
@@ -80,10 +98,7 @@ class TwdManager:
80
98
  next(reader) # skip headers
81
99
 
82
100
  for row in reader:
83
- try:
84
- entries.append(Entry.from_csv(row))
85
- except Exception as e:
86
- print(f"Warning: skipping invalid row: {e}")
101
+ entries.append(Entry.from_csv(row))
87
102
 
88
103
  return entries
89
104
 
@@ -121,6 +136,16 @@ class TwdManager:
121
136
 
122
137
  return None
123
138
 
139
+ def update(self, alias: str, entry: Entry) -> bool:
140
+ """update TWD by alias"""
141
+ if not self.exists(alias):
142
+ return False
143
+
144
+ # simplest form of update is remove and add
145
+ self.remove(alias)
146
+
147
+ self.add(entry.alias, entry.path, entry.name)
148
+
124
149
  def remove(self, alias: str) -> None:
125
150
  """remove entry by alias"""
126
151
  entries = self._read_all()
@@ -0,0 +1,4 @@
1
+ from twd.modals.confirm import ConfirmModal, EntryDeleteModal
2
+ from twd.modals.edit import EditEntryModal
3
+
4
+ __all__ = ["ConfirmModal", "EntryDeleteModal", "EditEntryModal"]
@@ -0,0 +1,97 @@
1
+ from textual import on
2
+ from textual.app import ComposeResult
3
+ from textual.screen import ModalScreen
4
+ from textual.containers import Container, Horizontal
5
+ from textual.widgets import Button, Label
6
+ from typing import Union, TypeVar, Generic
7
+
8
+ from twd.data import Entry
9
+
10
+ T = TypeVar('T')
11
+
12
+ class ConfirmModal(ModalScreen[T], Generic[T]):
13
+ """A confirm modal"""
14
+
15
+ DEFAULT_CSS = """
16
+ ConfirmModal {
17
+ align: center middle;
18
+ }
19
+
20
+ ConfirmModal > Container {
21
+ width: auto;
22
+ height: auto;
23
+ border: thick $background 80%;
24
+ background: $surface;
25
+ }
26
+
27
+ ConfirmModal > Container > Label {
28
+ width: 100%;
29
+ content-align-horizontal: center;
30
+ margin-top: 1;
31
+ }
32
+
33
+ ConfirmModal > Container > Horizontal {
34
+ width: auto;
35
+ height: auto;
36
+ }
37
+
38
+ ConfirmModal > Container > Horizontal > Button {
39
+ margin: 2 4;
40
+ }
41
+ """
42
+
43
+ def __init__(
44
+ self,
45
+ message: Union[str, None] = None,
46
+ confirm_text: str = "Yes",
47
+ cancel_text: str = "No",
48
+ confirm_value: T | None = None,
49
+ cancel_value: T | None = None,
50
+ ):
51
+ """
52
+ message: The message to display when popping the modal
53
+ confirm_text: Text to show for confirmation
54
+ cancel_text: Text to show for cancellation
55
+ """
56
+ self.message = message
57
+ self.confirm_text = confirm_text
58
+ self.cancel_text = cancel_text
59
+
60
+ self.confirm_value = confirm_value
61
+ self.cancel_value = cancel_value
62
+
63
+ super().__init__()
64
+
65
+ def compose_content(self) -> ComposeResult:
66
+ """
67
+ Abstract method for presenting custom shenanigans
68
+ """
69
+ if self.message:
70
+ yield Label(self.message, id="content")
71
+ else:
72
+ yield Label("Are you sure?", id="content")
73
+
74
+ def compose(self) -> ComposeResult:
75
+ with Container():
76
+ yield from self.compose_content()
77
+ with Horizontal():
78
+ yield Button(self.cancel_text, id="cancel", variant="error")
79
+ yield Button(self.confirm_text, id="confirm", variant="success")
80
+
81
+ @on(Button.Pressed, "#cancel")
82
+ def cancel_pressed(self) -> None:
83
+ self.dismiss(self.cancel_value)
84
+
85
+ @on(Button.Pressed, "#confirm")
86
+ def confirm_pressed(self) -> None:
87
+ self.dismiss(self.confirm_value)
88
+
89
+ class EntryDeleteModal(ConfirmModal):
90
+ """Confirmation modal with detailed entry information"""
91
+
92
+ def __init__(self, entry):
93
+ self.entry = entry
94
+ super().__init__(confirm_text="Delete", cancel_text="Cancel")
95
+
96
+ def compose_content(self) -> ComposeResult:
97
+ yield Label(f"Delete entry '{self.entry.name}'?", id="content")
@@ -0,0 +1,52 @@
1
+ from textual import on
2
+ from textual.app import ComposeResult
3
+ from textual.containers import Container, Horizontal
4
+ from textual.widgets import Button, Label, Input
5
+
6
+ from twd.data import Entry
7
+ from twd.modals import ConfirmModal
8
+
9
+ class EditEntryModal(ConfirmModal[Entry | None]):
10
+ """A modal to edit an existing entry"""
11
+
12
+ DEFAULT_CSS = """
13
+ Label {
14
+ width: 100%;
15
+ text-align: left;
16
+ }
17
+ Input {
18
+ margin: 1;
19
+ }
20
+ """
21
+
22
+ def __init__(self, entry):
23
+ """
24
+ entry: the entry to edit
25
+ """
26
+ self.entry = Entry.from_values(entry.alias, entry.path, entry.name, entry.created_at)
27
+
28
+ super().__init__(
29
+ confirm_text="Save",
30
+ cancel_text="Discard",
31
+ confirm_value=self.entry,
32
+ )
33
+
34
+ def compose_content(self) -> ComposeResult:
35
+ yield Label(f"Edit Entry", id="title")
36
+
37
+ yield Label("Alias")
38
+ yield Input(value=self.entry.alias, id="alias", disabled=True)
39
+ yield Label("Path")
40
+ yield Input(value=str(self.entry.path), id="path")
41
+ yield Label("Name")
42
+ yield Input(value=self.entry.name, id="name")
43
+
44
+ @on(Input.Changed, "#path")
45
+ def on_path_change(self, event: Input.Changed) -> None:
46
+ self.entry.path = event.value
47
+
48
+ @on(Input.Changed, "#name")
49
+ def on_name_change(self, event: Input.Changed) -> None:
50
+ self.entry.name = event.value
51
+
52
+
@@ -1,4 +1,5 @@
1
1
  from enum import Enum
2
+ from datetime import datetime
2
3
  from textual import on
3
4
  from textual.app import App, ComposeResult, Binding
4
5
  from textual.containers import HorizontalGroup, VerticalScroll
@@ -7,8 +8,9 @@ from textual.widgets import Button, Digits, Footer, Header, DataTable, Label, Ru
7
8
  from textual.color import Color
8
9
 
9
10
  from twd.config import Config
10
- from twd.data import TwdManager
11
+ from twd.data import TwdManager, Entry
11
12
  from twd.utils import fuzzy_search, linear_search
13
+ from twd.modals import ConfirmModal, EntryDeleteModal, EditEntryModal
12
14
 
13
15
  class Mode(Enum):
14
16
  NORMAL = "normal"
@@ -27,8 +29,10 @@ class TWDApp(App):
27
29
  Binding("k", "cursor_up", "Up"),
28
30
 
29
31
  # modify
30
- Binding("/", "enter_search_mode", "Search"),
31
- Binding("escape", "enter_normal_mode", "Normal", show=False),
32
+ Binding("/", "slash_key", "Search"),
33
+ Binding("d", "d_key", "Delete"),
34
+ Binding("e", "e_key", "Edit"),
35
+ Binding("escape", "escape_key", "Normal", show=False),
32
36
  # TODO: edit
33
37
  # TODO: rename
34
38
 
@@ -40,6 +44,7 @@ class TWDApp(App):
40
44
  ]
41
45
 
42
46
  mode: Mode = reactive(Mode.NORMAL)
47
+ search_results = None
43
48
 
44
49
  def __init__(self, manager: TwdManager, *args, **kwargs):
45
50
  self.manager = manager
@@ -77,7 +82,7 @@ class TWDApp(App):
77
82
 
78
83
  # add headers
79
84
  table = self.query_one(DataTable)
80
- table.add_columns(*self.manager.CSV_HEADERS)
85
+ table.add_columns(*self.manager.CSV_HEADERS_FANCY)
81
86
 
82
87
  self._populate_table()
83
88
 
@@ -93,7 +98,20 @@ class TWDApp(App):
93
98
 
94
99
  # fill data
95
100
  for entry in entries:
96
- table.add_row(entry.alias, str(entry.path), entry.name, entry.created_at)
101
+ table.add_row(entry.alias, str(entry.path), entry.name, entry.created_at.strftime("%Y-%m-%d %H:%M:%S"))
102
+
103
+ def _current_row_entry(self) -> Entry:
104
+ table = self.query_one(DataTable)
105
+
106
+ # get row
107
+ row_key = table.coordinate_to_cell_key(table.cursor_coordinate).row_key
108
+ row_data = table.get_row(row_key)
109
+ alias = row_data[0]
110
+
111
+ # get entry
112
+ entry = self.manager.get(alias)
113
+
114
+ return entry
97
115
 
98
116
  def watch_mode(self, old_mode: Mode, new_mode: Mode) -> None:
99
117
  """
@@ -128,8 +146,11 @@ class TWDApp(App):
128
146
  """
129
147
  table = self.query_one(DataTable)
130
148
 
131
- current_row = table.cursor_coordinate.row
132
- next_row = (current_row + 1) % table.row_count
149
+ try:
150
+ current_row = table.cursor_coordinate.row
151
+ next_row = (current_row + 1) % table.row_count
152
+ except ZeroDivisionError as e:
153
+ return
133
154
 
134
155
  table.move_cursor(row=next_row)
135
156
 
@@ -139,12 +160,15 @@ class TWDApp(App):
139
160
  """
140
161
  table = self.query_one(DataTable)
141
162
 
142
- current_row = table.cursor_coordinate.row
143
- prev_row = (current_row - 1) % table.row_count
163
+ try:
164
+ current_row = table.cursor_coordinate.row
165
+ prev_row = (current_row - 1) % table.row_count
166
+ except ZeroDivisionError as e:
167
+ return
144
168
 
145
169
  table.move_cursor(row=prev_row)
146
170
 
147
- def action_enter_search_mode(self) -> None:
171
+ def action_slash_key(self) -> None:
148
172
  """
149
173
  enter search mode
150
174
  """
@@ -152,14 +176,62 @@ class TWDApp(App):
152
176
  return
153
177
  self.mode = Mode.SEARCH
154
178
 
155
- def action_enter_normal_mode(self) -> None:
179
+ def action_escape_key(self) -> None:
156
180
  """
157
181
  enter normal mode
158
182
  """
159
183
  if self.mode == Mode.NORMAL:
184
+ if self.search_results is not None:
185
+ self._populate_table()
186
+ self.search_results = None
160
187
  return
161
188
  self.mode = Mode.NORMAL
162
189
 
190
+ def action_d_key(self) -> None:
191
+ """
192
+ open confirm modal and delete entry if yes
193
+ """
194
+ if not self.mode == Mode.NORMAL:
195
+ return
196
+
197
+ entry = self._current_row_entry()
198
+
199
+ def check_delete(decision: bool | None) -> None:
200
+ """
201
+ open modal and return the decision
202
+ """
203
+ if not decision:
204
+ return
205
+
206
+ self.manager.remove(alias)
207
+
208
+ self._populate_table()
209
+
210
+ self.notify(f"Removed entry \"{entry.name}\"")
211
+
212
+ self.push_screen(EntryDeleteModal(entry), check_delete)
213
+
214
+ def action_e_key(self) -> None:
215
+ """
216
+ open edit modal and edit entry in place
217
+ """
218
+ entry = self._current_row_entry()
219
+
220
+ def save_new_entry(new_entry: Entry | None) -> None:
221
+ if not new_entry or entry == new_entry:
222
+ # user hit 'Discard'
223
+ # no changes so no update
224
+ self.notify(f"No changes")
225
+ return
226
+
227
+ self.notify(f"Updated TWD '{new_entry.alias}'")
228
+
229
+ self.manager.update(new_entry.alias, new_entry)
230
+ self.manager._write_all(self.manager._read_all())
231
+ self._populate_table()
232
+
233
+ self.push_screen(EditEntryModal(entry), save_new_entry)
234
+
163
235
  def action_exit(self) -> None:
164
236
  self.exit()
165
237
 
@@ -186,6 +258,7 @@ class TWDApp(App):
186
258
  filtered = [item[0] for item in search_result]
187
259
 
188
260
  self._populate_table(filtered)
261
+ self.search_results = filtered
189
262
 
190
263
  @on(Input.Submitted, "#search-input")
191
264
  def on_search_submitted(self, e: Input.Submitted) -> None:
@@ -196,6 +269,7 @@ class TWDApp(App):
196
269
  return
197
270
 
198
271
  self.mode = Mode.NORMAL
272
+ self._populate_table(self.search_results)
199
273
 
200
274
  self.query_one(DataTable).focus()
201
275
 
@@ -208,12 +282,7 @@ class TWDApp(App):
208
282
  table = event.data_table
209
283
  row_key = event.row_key
210
284
 
211
- # get row
212
- row_data = table.get_row(row_key)
213
- alias = row_data[0]
214
-
215
- # get entry
216
- entry = self.manager.get(alias)
285
+ entry = self._current_row_entry()
217
286
 
218
287
  self.notify(f"Selected: {entry.alias} -> {entry.path}")
219
288
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: twd-m4sc0
3
- Version: 3.0.4
3
+ Version: 3.1.1
4
4
  Summary: TWD by m4sc0
5
5
  Author: m4sc0
6
6
  License: MIT
@@ -7,6 +7,9 @@ src/twd/data.py
7
7
  src/twd/tui.py
8
8
  src/twd/tui.tcss
9
9
  src/twd/utils.py
10
+ src/twd/modals/__init__.py
11
+ src/twd/modals/confirm.py
12
+ src/twd/modals/edit.py
10
13
  src/twd_m4sc0.egg-info/PKG-INFO
11
14
  src/twd_m4sc0.egg-info/SOURCES.txt
12
15
  src/twd_m4sc0.egg-info/dependency_links.txt
@@ -1,6 +0,0 @@
1
- from importlib.metadata import version, PackageNotFoundError
2
-
3
- try:
4
- __version__ = version("twd")
5
- except PackageNotFoundError as e:
6
- __version__ = "unknown"
File without changes
File without changes
File without changes
File without changes
File without changes