Skip to content

Commit c8dda74

Browse files
committed
feat(cli): enhance file copying functionality with status reporting and improved user prompts
1 parent eef5c9d commit c8dda74

File tree

17 files changed

+145
-115
lines changed

17 files changed

+145
-115
lines changed

src/htpy_uikit/cli.py

Lines changed: 55 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
import shutil
55
import sys
66
import tomllib
7+
from collections import Counter
8+
from enum import Enum
79
from pathlib import Path
810
from typing import Iterable
911
from typing import Sequence
@@ -94,17 +96,34 @@ def _resolve_dependencies(entry_files: Sequence[Path]) -> list[Path]:
9496
return internals + components
9597

9698

97-
def _copy_file(src: Path, dest_dir: Path, force: bool) -> Path:
99+
class CopyStatus(Enum):
100+
COPIED = "copied"
101+
SKIPPED_UNCHANGED = "skipped_unchanged"
102+
SKIPPED_USER = "skipped_user"
103+
104+
105+
def _copy_file(
106+
src: Path,
107+
dest_dir: Path,
108+
force: bool,
109+
dest_name: str | None = None,
110+
) -> tuple[Path, CopyStatus]:
98111
dest_dir.mkdir(parents=True, exist_ok=True)
99-
dest = dest_dir / src.name
100-
if dest.exists() and not force:
101-
overwrite = questionary.confirm(f"{dest} exists. Overwrite?", default=False).ask()
102-
if not overwrite:
103-
click.echo(f"Skipped {dest}")
104-
return dest
112+
name = dest_name or src.name
113+
dest = dest_dir / name
114+
if dest.exists():
115+
src_bytes = src.read_bytes()
116+
dest_bytes = dest.read_bytes()
117+
if dest_bytes == src_bytes:
118+
return dest, CopyStatus.SKIPPED_UNCHANGED
119+
if not force:
120+
overwrite = questionary.confirm(f"{dest} exists. Overwrite?", default=False).ask()
121+
if not overwrite:
122+
click.echo(f"Skipped {dest}")
123+
return dest, CopyStatus.SKIPPED_USER
105124
shutil.copy2(src, dest)
106125
click.echo(f"Copied {src.name} -> {dest}")
107-
return dest
126+
return dest, CopyStatus.COPIED
108127

109128

110129
def _find_pyproject(start: Path | None = None) -> Path | None:
@@ -252,8 +271,27 @@ def add_cmd(components: tuple[str, ...], dest: Path | None, select_all: bool, fo
252271
init_py = dest / "__init__.py"
253272
if not init_py.exists():
254273
_write_text(init_py, "")
274+
status_counts: Counter[CopyStatus] = Counter()
255275
for src in files:
256-
_copy_file(src, dest, force=force)
276+
_, status = _copy_file(src, dest, force=force)
277+
status_counts[status] += 1
278+
279+
copied = status_counts[CopyStatus.COPIED]
280+
if copied:
281+
suffix = "s" if copied != 1 else ""
282+
click.echo(f"\nCopied {copied} file{suffix}.")
283+
284+
summaries: list[str] = []
285+
unchanged = status_counts[CopyStatus.SKIPPED_UNCHANGED]
286+
if unchanged:
287+
suffix = "s" if unchanged != 1 else ""
288+
summaries.append(f"{unchanged} file{suffix} already up-to-date")
289+
skipped = status_counts[CopyStatus.SKIPPED_USER]
290+
if skipped:
291+
suffix = "s" if skipped != 1 else ""
292+
summaries.append(f"{skipped} file{suffix} overwrite declined")
293+
if summaries:
294+
click.echo(f"Skipped {'; '.join(summaries)}")
257295

258296
click.echo("\nDone. Remember to run your Tailwind build.")
259297

@@ -295,13 +333,14 @@ def add_theme_cmd(theme: str | None, dest: Path | None, force: bool) -> None:
295333
if dest is None:
296334
dest = default_dest
297335

298-
if dest.exists() and not force:
299-
if not questionary.confirm(f"{dest} exists. Overwrite?", default=False).ask():
300-
click.echo("Skipped theme.")
301-
return
302-
content = _read_text(src)
303-
_write_text(dest, content)
304-
click.echo(f"Copied {src.name} -> {dest}")
336+
_, status = _copy_file(
337+
src,
338+
dest.parent,
339+
force=force,
340+
dest_name=dest.name,
341+
)
342+
if status != CopyStatus.COPIED:
343+
return
305344

306345

307346
@cli.command("themes")

src/htpy_uikit/components/badge.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,12 +29,12 @@ def _compute_badge_classes(variant: BadgeVariant, extra: str | None = None) -> s
2929
var = "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90"
3030
elif variant == "destructive":
3131
var = (
32-
"border-transparent bg-destructive text-destructive-foreground [a&]:hover:bg-destructive/90 "
33-
"focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60"
32+
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 "
33+
"focus-visible:ring-destructive/20"
3434
)
3535
elif variant == "outline":
3636
var = (
37-
"bg-background dark:bg-input/30 border border-border text-foreground "
37+
"bg-background border border-border text-foreground "
3838
"[a&]:hover:bg-accent [a&]:hover:text-accent-foreground"
3939
)
4040
else:
@@ -215,7 +215,7 @@ def badge_link(
215215
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium h-6 "
216216
"whitespace-nowrap shrink-0 gap-1 [&>svg]:size-3 [&>svg]:pointer-events-none transition-[color,box-shadow] overflow-hidden"
217217
)
218-
outline = "bg-background dark:bg-input/30 border border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground"
218+
outline = "bg-background border border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground"
219219
classes = f"{base} {outline}"
220220
if class_:
221221
classes = f"{classes} {class_}"

src/htpy_uikit/components/button.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ def button_component(
4747
"inline-flex items-center justify-center whitespace-nowrap text-sm font-medium transition-all "
4848
"disabled:cursor-not-allowed disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 "
4949
"shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 "
50-
"focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 "
50+
"focus-visible:ring-[3px] aria-invalid:ring-destructive/20 "
5151
"aria-invalid:border-destructive cursor-pointer rounded-md gap-2 h-9 px-4 has-[>svg]:px-3"
5252
)
5353

@@ -61,14 +61,14 @@ def button_component(
6161
# Use white text on destructive buttons to match reference (strong contrast)
6262
"destructive": (
6363
"bg-destructive text-white shadow-xs focus-visible:ring-destructive/20 "
64-
"dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 hover:bg-destructive/90 dark:hover:bg-destructive/50"
64+
"hover:bg-destructive/90"
6565
),
66-
"outline": "border bg-background shadow-xs dark:bg-input/30 dark:border-input hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
66+
"outline": "border border-input bg-background shadow-xs hover:bg-accent hover:text-accent-foreground",
6767
"ghost": "hover:bg-accent hover:text-accent-foreground",
6868
"link": "text-primary underline-offset-4 hover:underline",
6969
"danger": (
7070
"bg-destructive text-white shadow-xs focus-visible:ring-destructive/20 "
71-
"dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 hover:bg-destructive/90 dark:hover:bg-destructive/50"
71+
"hover:bg-destructive/90"
7272
),
7373
}
7474
# All variants should be handled in the dictionary

src/htpy_uikit/components/checkbox.py

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -45,10 +45,10 @@ def checkbox_component(
4545
# Base classes – Tailwind utilities matching Basecoat checkbox styles
4646
# Checked indicator using :after pseudo-element with mask icon
4747
base_classes = (
48-
"appearance-none cursor-pointer border-input dark:bg-input/30 checked:bg-primary dark:checked:bg-primary "
49-
"checked:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 "
50-
"dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border "
51-
"shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 "
48+
"appearance-none cursor-pointer border-input bg-card checked:bg-primary checked:border-primary "
49+
"focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 aria-invalid:border-destructive "
50+
"size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] "
51+
"disabled:cursor-not-allowed disabled:opacity-50 "
5252
"checked:after:content-[''] checked:after:block checked:after:size-3.5 checked:after:bg-primary-foreground "
5353
"checked:after:mask-[image:var(--check-icon)] checked:after:mask-size-[0.875rem] checked:after:mask-no-repeat checked:after:mask-center"
5454
)
@@ -158,10 +158,10 @@ def checkbox_card_component(
158158
# Base input classes (same as reference "input" class)
159159
# Position the checkbox absolutely so it appears inside the card area
160160
input_classes = (
161-
"peer absolute left-4 top-4 z-20 appearance-none border-input dark:bg-input/30 checked:bg-primary dark:checked:bg-primary "
162-
"checked:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 "
163-
"dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border "
164-
"shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 "
161+
"peer absolute left-4 top-4 z-20 appearance-none border-input bg-card checked:bg-primary checked:border-primary "
162+
"focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 aria-invalid:border-destructive "
163+
"size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] "
164+
"disabled:cursor-not-allowed disabled:opacity-50 "
165165
"checked:after:content-[''] checked:after:block checked:after:size-3.5 checked:after:bg-primary-foreground "
166166
"checked:after:mask-[image:var(--check-icon)] checked:after:mask-size-[0.875rem] checked:after:mask-no-repeat checked:after:mask-center"
167167
)
@@ -196,13 +196,13 @@ def checkbox_card_component(
196196
# Determine card-inner classes to color full card area on checked state and hover effects
197197
if card_color == "blue":
198198
classes_card_checked = "peer-checked:bg-blue-700/30 peer-checked:border-blue-700/60"
199-
classes_card_hover = "peer-not-checked:hover:bg-blue-50 peer-not-checked:hover:border-blue-200 dark:peer-not-checked:hover:bg-blue-950/20 dark:peer-not-checked:hover:border-blue-800/40"
199+
classes_card_hover = "peer-not-checked:hover:bg-blue-50 peer-not-checked:hover:border-blue-200"
200200
elif card_color == "green":
201201
classes_card_checked = "peer-checked:bg-green-700/30 peer-checked:border-green-700/60"
202-
classes_card_hover = "peer-not-checked:hover:bg-green-50 peer-not-checked:hover:border-green-200 dark:peer-not-checked:hover:bg-green-950/20 dark:peer-not-checked:hover:border-green-800/40"
202+
classes_card_hover = "peer-not-checked:hover:bg-green-50 peer-not-checked:hover:border-green-200"
203203
elif card_color == "red":
204204
classes_card_checked = "peer-checked:bg-red-700/30 peer-checked:border-red-700/60"
205-
classes_card_hover = "peer-not-checked:hover:bg-red-50 peer-not-checked:hover:border-red-200 dark:peer-not-checked:hover:bg-red-950/20 dark:peer-not-checked:hover:border-red-800/40"
205+
classes_card_hover = "peer-not-checked:hover:bg-red-50 peer-not-checked:hover:border-red-200"
206206
else:
207207
assert_never(card_color)
208208

src/htpy_uikit/components/combobox.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -292,7 +292,7 @@ def combobox(
292292
if (enter) {{
293293
// apply hover bg only when not selected
294294
if (!el.classList.contains('bg-accent')) {{
295-
el.style.background = 'rgba(255,255,255,0.03)';
295+
el.style.background = 'var(--color-muted, rgba(255,255,255,0.03))';
296296
}}
297297
}} else {{
298298
// remove hover bg; restore selected bg if needed

src/htpy_uikit/components/dialog.py

Lines changed: 15 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -55,19 +55,18 @@ def dialog(
5555
div(
5656
**{"role": "dialog", "aria-modal": "true"},
5757
class_=(
58-
"relative z-50 grid w-full max-w-lg gap-4 border border-gray-200 bg-white "
59-
"p-6 shadow-lg duration-200 sm:rounded-lg dark:border-gray-800 dark:bg-gray-950"
58+
"relative z-50 grid w-full max-w-lg gap-4 border border-border bg-card "
59+
"p-6 shadow-lg duration-200 sm:rounded-lg"
6060
),
6161
)[
6262
# Close button
6363
button(
64-
class_=(
65-
"absolute right-4 top-4 rounded-sm opacity-70 ring-offset-white "
66-
"transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 "
67-
"focus:ring-gray-400 focus:ring-offset-2 disabled:pointer-events-none "
68-
"data-[state=open]:bg-gray-100 dark:ring-offset-gray-950 dark:focus:ring-gray-800 "
69-
"dark:data-[state=open]:bg-gray-800"
70-
),
64+
class_=(
65+
"absolute right-4 top-4 rounded-sm opacity-70 ring-offset-white "
66+
"transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 "
67+
"focus:ring-ring/50 focus:ring-offset-2 disabled:pointer-events-none "
68+
"data-[state=open]:bg-muted/20"
69+
),
7170
**{"data-state": "closed"},
7271
)[
7372
svg(
@@ -82,7 +81,7 @@ def dialog(
8281
# Dialog header
8382
div(class_="flex flex-col space-y-1.5 text-center sm:text-left")[
8483
h2(class_="text-lg font-semibold leading-none tracking-tight")[title or ""],
85-
p(class_="text-sm text-gray-500 dark:text-gray-400")[description or ""],
84+
p(class_="text-sm text-muted-foreground")[description or ""],
8685
],
8786
],
8887
]
@@ -147,7 +146,7 @@ def dialog_description(children: Node, *, class_: Optional[str] = None, **attrs)
147146
Returns:
148147
Renderable: ``<p>`` node with muted styling.
149148
"""
150-
classes = "text-sm text-gray-500 dark:text-gray-400"
149+
classes = "text-sm text-muted-foreground"
151150
attrs["class_"] = merge_classes(classes, class_)
152151

153152
return p(**attrs)[children]
@@ -181,11 +180,10 @@ def dialog_close_button(*, class_: Optional[str] = None, **attrs) -> Renderable:
181180
Renderable: Close button renderable.
182181
"""
183182
classes = (
184-
"mt-2 inline-flex h-10 items-center justify-center rounded-md border border-gray-300 "
185-
"bg-white px-4 py-2 text-sm font-medium ring-offset-white transition-colors hover:bg-gray-50 "
186-
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-400 focus-visible:ring-offset-2 "
187-
"disabled:pointer-events-none disabled:opacity-50 dark:border-gray-700 dark:bg-gray-950 "
188-
"dark:ring-offset-gray-950 dark:hover:bg-gray-800 dark:focus-visible:ring-gray-800 sm:mt-0"
183+
"mt-2 inline-flex h-10 items-center justify-center rounded-md border border-border "
184+
"bg-background px-4 py-2 text-sm font-medium ring-offset-white transition-colors hover:bg-muted/10 "
185+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-offset-2 "
186+
"disabled:pointer-events-none disabled:opacity-50 sm:mt-0"
189187
)
190188
attrs["class_"] = merge_classes(classes, class_)
191189

@@ -207,8 +205,7 @@ def dialog_action_button(children: Node, *, class_: Optional[str] = None, **attr
207205
"inline-flex h-10 items-center justify-center rounded-md bg-blue-600 px-4 py-2 "
208206
"text-sm font-medium text-white transition-colors hover:bg-blue-700 focus-visible:outline-none "
209207
"focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 "
210-
"disabled:pointer-events-none disabled:opacity-50 dark:bg-blue-600 dark:hover:bg-blue-700 "
211-
"dark:focus-visible:ring-blue-300"
208+
"disabled:pointer-events-none disabled:opacity-50"
212209
)
213210
attrs["class_"] = merge_classes(classes, class_)
214211

src/htpy_uikit/components/input.py

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -52,14 +52,13 @@ def input_component(
5252

5353
base_classes = (
5454
"appearance-none file:text-foreground placeholder:text-muted-foreground "
55-
"selection:bg-primary selection:text-primary-foreground dark:bg-input/30 "
56-
"border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent "
55+
"selection:bg-primary selection:text-primary-foreground border-input "
56+
"flex h-9 w-full min-w-0 rounded-md border bg-card "
5757
"px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none "
5858
"file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm "
59-
"file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed "
59+
"file:font-medium disabled:cursor-not-allowed "
6060
"disabled:opacity-50 md:text-sm focus-visible:border-ring focus-visible:ring-ring/50 "
61-
"focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 "
62-
"aria-invalid:border-destructive"
61+
"focus-visible:ring-[3px] aria-invalid:ring-destructive/20 aria-invalid:border-destructive"
6362
)
6463

6564
# Build class list

src/htpy_uikit/components/modal.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ def _modal_panel(
4242
"type": "button",
4343
"class_": (
4444
"cursor-pointer text-muted-foreground bg-transparent hover:text-foreground rounded-lg "
45-
"text-sm w-8 h-8 ms-auto inline-flex justify-center items-center hover:bg-accent dark:hover:text-white"
45+
"text-sm w-8 h-8 ms-auto inline-flex justify-center items-center hover:bg-accent hover:text-white"
4646
),
4747
}
4848
if close_button_attrs:

src/htpy_uikit/components/pagination.py

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,12 @@
1919
"inline-flex items-center justify-center whitespace-nowrap text-sm font-medium "
2020
"disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 shrink-0 "
2121
"outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] "
22-
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive "
22+
"aria-invalid:ring-destructive/20 aria-invalid:border-destructive "
2323
"cursor-pointer rounded-md"
2424
)
2525
variant_ghost = "hover:bg-accent hover:text-accent-foreground"
2626
variant_outline = (
27-
"border bg-background shadow-xs dark:bg-input/30 dark:border-input hover:bg-accent "
28-
"hover:text-accent-foreground dark:hover:bg-accent/50"
27+
"border bg-background shadow-xs border-input hover:bg-accent hover:text-accent-foreground"
2928
)
3029
size_text = {
3130
"sm": "gap-1.5 h-8 px-3 has-[>svg]:px-2.5 text-xs",
@@ -121,7 +120,7 @@ def build_url(page: int) -> str:
121120
a(
122121
class_=prev_classes,
123122
href=prev_url if not is_disabled else None,
124-
**{"aria-label": "Previous page"},
123+
**{"aria-label": "Previous page", "hx-boost": "true"},
125124
)[
126125
icon_chevron_left(class_="size-4 shrink-0"),
127126
span()[" Previous"],
@@ -136,6 +135,7 @@ def build_url(page: int) -> str:
136135
a(
137136
class_=classes_btn("ghost", icon=True, size_key=size),
138137
href=build_url(1),
138+
**{"hx-boost": "true"},
139139
)["1"]
140140
]
141141
)
@@ -156,7 +156,7 @@ def build_url(page: int) -> str:
156156
a(
157157
class_=page_classes,
158158
href="#" if is_current else build_url(page),
159-
**{"aria-current": "page" if is_current else None},
159+
**{"aria-current": "page" if is_current else None, "hx-boost": "true"},
160160
)[str(page)]
161161
]
162162
)
@@ -171,6 +171,7 @@ def build_url(page: int) -> str:
171171
a(
172172
class_=classes_btn("ghost", icon=True, size_key=size),
173173
href=build_url(total_pages),
174+
**{"hx-boost": "true"},
174175
)[str(total_pages)]
175176
]
176177
)
@@ -189,7 +190,7 @@ def build_url(page: int) -> str:
189190
a(
190191
class_=next_classes,
191192
href=next_url if not is_disabled else None,
192-
**{"aria-label": "Next page"},
193+
**{"aria-label": "Next page", "hx-boost": "true"},
193194
)[
194195
span()["Next "],
195196
icon_chevron_right(class_="size-4 shrink-0"),

0 commit comments

Comments
 (0)