Skip to content

Commit a11428f

Browse files
committed
Handle rename review findings
1 parent 1547c77 commit a11428f

4 files changed

Lines changed: 62 additions & 33 deletions

File tree

src/vue-tui/component.ts

Lines changed: 37 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -150,19 +150,16 @@ export function createTmuRoot(options: TmuRootOptions) {
150150
} else if (key.escape) {
151151
coordinator.dispatchUi({ type: "dismissRenameDialog" });
152152
} else if (key.return) {
153-
const title = ui.renameDialog.value.trim();
154-
if (!title) {
155-
coordinator.dispatchUi({ type: "setRenameDialogError", error: "Track Title must not be empty" });
156-
} else {
157-
try {
158-
await coordinator.dispatch({ type: "renameTrack", identity: ui.renameDialog.identity, title });
159-
coordinator.dispatchUi({ type: "dismissRenameDialog" });
160-
} catch (error) {
161-
coordinator.dispatchUi({
162-
type: "setRenameDialogError",
163-
error: error instanceof Error ? error.message : String(error),
164-
});
165-
}
153+
try {
154+
await coordinator.dispatch({
155+
type: "renameTrack", identity: ui.renameDialog.identity, title: ui.renameDialog.value,
156+
});
157+
coordinator.dispatchUi({ type: "dismissRenameDialog" });
158+
} catch (error) {
159+
coordinator.dispatchUi({
160+
type: "setRenameDialogError",
161+
error: error instanceof Error ? error.message : String(error),
162+
});
166163
}
167164
} else {
168165
editRenameDialog(input, key, coordinator);
@@ -742,8 +739,9 @@ function confirmationModal(confirmation: ConfirmationDescriptor, ui: UiState, no
742739

743740
function renameTrackModal(dialog: NonNullable<UiState["renameDialog"]>, noColor: boolean) {
744741
const beforeCursor = dialog.value.slice(0, dialog.cursor);
745-
const atCursor = dialog.value[dialog.cursor] ?? " ";
746-
const afterCursor = dialog.value.slice(dialog.cursor + (dialog.cursor < dialog.value.length ? 1 : 0));
742+
const nextCursor = nextGraphemeBoundary(dialog.value, dialog.cursor);
743+
const atCursor = dialog.value.slice(dialog.cursor, nextCursor) || " ";
744+
const afterCursor = dialog.value.slice(nextCursor);
747745
return h(Box, {
748746
flexDirection: "column", borderStyle: "round", borderColor: noColor ? undefined : "cyan",
749747
paddingX: 2, alignSelf: "center", width: "70%",
@@ -1049,22 +1047,41 @@ function editRenameDialog(input: string, key: InputKey, coordinator: AppCoordina
10491047
const dialog = coordinator.uiState.renameDialog;
10501048
if (!dialog) return;
10511049
let { value, cursor } = dialog;
1052-
if (key.leftArrow) cursor = Math.max(0, cursor - 1);
1053-
else if (key.rightArrow) cursor = Math.min(value.length, cursor + 1);
1050+
if (key.leftArrow) cursor = previousGraphemeBoundary(value, cursor);
1051+
else if (key.rightArrow) cursor = nextGraphemeBoundary(value, cursor);
10541052
else if (key.home) cursor = 0;
10551053
else if (key.end) cursor = value.length;
10561054
else if (key.backspace && cursor > 0) {
1057-
value = value.slice(0, cursor - 1) + value.slice(cursor);
1058-
cursor -= 1;
1055+
const previousCursor = previousGraphemeBoundary(value, cursor);
1056+
value = value.slice(0, previousCursor) + value.slice(cursor);
1057+
cursor = previousCursor;
10591058
} else if (key.delete && cursor < value.length) {
1060-
value = value.slice(0, cursor) + value.slice(cursor + 1);
1059+
value = value.slice(0, cursor) + value.slice(nextGraphemeBoundary(value, cursor));
10611060
} else if (input.length > 0 && !key.ctrl && !key.meta) {
10621061
value = value.slice(0, cursor) + input + value.slice(cursor);
10631062
cursor += input.length;
10641063
} else return;
10651064
coordinator.dispatchUi({ type: "editRenameDialog", value, cursor });
10661065
}
10671066

1067+
const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
1068+
1069+
function previousGraphemeBoundary(value: string, cursor: number): number {
1070+
let previous = 0;
1071+
for (const segment of graphemeSegmenter.segment(value)) {
1072+
if (segment.index >= cursor) break;
1073+
previous = segment.index;
1074+
}
1075+
return previous;
1076+
}
1077+
1078+
function nextGraphemeBoundary(value: string, cursor: number): number {
1079+
for (const segment of graphemeSegmenter.segment(value)) {
1080+
if (segment.index > cursor) return segment.index;
1081+
}
1082+
return value.length;
1083+
}
1084+
10681085
function isCtrlC(input: string, key: InputKey): boolean {
10691086
return input === "\x03" || (key.ctrl === true && input === "c");
10701087
}

src/youtube-cache.ts

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ const YOUTUBE_CACHE_PROVIDER_LABEL = "YouTube Cache";
1616
const REQUIRED_METADATA_KEYS = [
1717
"videoId", "title", "uploader", "cachedAt", "mediaFileName", "container",
1818
] as const;
19-
const OPTIONAL_METADATA_KEYS = ["durationSeconds", "thumbnailUrl", "customTitle"] as const;
19+
const OPTIONAL_METADATA_KEYS = ["durationSeconds", "thumbnailUrl", "trackTitleOverride"] as const;
2020
const METADATA_KEYS = new Set<string>([...REQUIRED_METADATA_KEYS, ...OPTIONAL_METADATA_KEYS]);
2121
const MEDIA_EXTENSIONS = new Set([
2222
".aac", ".flac", ".m4a", ".mka", ".mkv", ".mp3", ".mp4", ".ogg", ".opus", ".wav", ".webm",
@@ -39,7 +39,7 @@ export type YouTubeCacheMetadata = {
3939
mediaFileName: string;
4040
container: string;
4141
thumbnailUrl?: string;
42-
customTitle?: string;
42+
trackTitleOverride?: string;
4343
};
4444

4545
export type YouTubeCacheEntry = {
@@ -199,9 +199,9 @@ class FileSystemYouTubeCacheProvider implements YouTubeCacheProvider {
199199
async renameTrack(identity: TrackIdentity, title: string): Promise<Track> {
200200
const entry = this.findByIdentity(identity);
201201
if (!entry) throw new Error(`YouTube Cache entry is missing: ${identity.stableId}`);
202-
const customTitle = normalizeDisplayValue(title);
203-
if (!customTitle) throw new Error("Track Title must not be empty");
204-
await writeYouTubeCacheMetadata(this.options, { ...entry.metadata, customTitle });
202+
const trackTitleOverride = normalizeDisplayValue(title);
203+
if (!trackTitleOverride) throw new Error("Track Title must not be empty");
204+
await writeYouTubeCacheMetadata(this.options, { ...entry.metadata, trackTitleOverride });
205205
this.refresh();
206206
return this.findByIdentity(identity)!.track;
207207
}
@@ -352,21 +352,21 @@ function normalizeMetadata(value: unknown): YouTubeCacheMetadata | null {
352352
if ("durationSeconds" in value && durationSeconds === undefined) return null;
353353
const thumbnailUrl = normalizeDisplayValue(value.thumbnailUrl);
354354
if ("thumbnailUrl" in value && !thumbnailUrl) return null;
355-
const customTitle = normalizeDisplayValue(value.customTitle);
356-
if ("customTitle" in value && !customTitle) return null;
355+
const trackTitleOverride = normalizeDisplayValue(value.trackTitleOverride);
356+
if ("trackTitleOverride" in value && !trackTitleOverride) return null;
357357
return {
358358
videoId, title, uploader,
359359
...(durationSeconds !== undefined ? { durationSeconds } : {}),
360360
cachedAt, mediaFileName, container,
361361
...(thumbnailUrl ? { thumbnailUrl } : {}),
362-
...(customTitle ? { customTitle } : {}),
362+
...(trackTitleOverride ? { trackTitleOverride } : {}),
363363
};
364364
}
365365

366366
function trackFromMetadata(metadata: YouTubeCacheMetadata): Track {
367367
return {
368368
identity: { providerId: YOUTUBE_CACHE_PROVIDER_ID, stableId: metadata.videoId },
369-
title: metadata.customTitle ?? metadata.title,
369+
title: metadata.trackTitleOverride ?? metadata.title,
370370
artist: metadata.uploader,
371371
...(metadata.durationSeconds !== undefined ? { durationSeconds: metadata.durationSeconds } : {}),
372372
providerLabel: YOUTUBE_CACHE_PROVIDER_LABEL,

tests/vue-tui.test.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1081,8 +1081,10 @@ describe("TMU top-level surface smoke", () => {
10811081
}],
10821082
listIncompleteEntries: () => [], findByIdentity: () => undefined,
10831083
renameTrack: async (_identity, title) => {
1084-
if (title === "Cannot Save") throw new Error("cache is read-only");
1085-
return (track = { ...track, title });
1084+
const normalizedTitle = title.trim();
1085+
if (!normalizedTitle) throw new Error("Track Title must not be empty");
1086+
if (normalizedTitle === "Cannot Save") throw new Error("cache is read-only");
1087+
return (track = { ...track, title: normalizedTitle });
10861088
},
10871089
deleteCacheEntry: async () => false, cleanupIncompleteEntry: async () => false,
10881090
};
@@ -1112,6 +1114,16 @@ describe("TMU top-level surface smoke", () => {
11121114

11131115
await terminal.stdin.write("e");
11141116
await terminal.stdin.write("\x7f".repeat("Clear Name".length));
1117+
await terminal.stdin.write("A🎵B");
1118+
await terminal.stdin.write("\x1b[H");
1119+
await terminal.stdin.write("\x1b[C");
1120+
await terminal.stdin.write("\x1b[C");
1121+
await terminal.stdin.write("\x7f");
1122+
await terminal.stdin.write("\r");
1123+
await waitFor(() => coordinator.appState.queue.entries[0]?.track.title === "AB");
1124+
1125+
await terminal.stdin.write("e");
1126+
await terminal.stdin.write("\x7f".repeat("AB".length));
11151127
await terminal.stdin.write(" ");
11161128
await terminal.stdin.write("\r");
11171129
expect(terminal.lastFrame()).toContain("Error: Track Title must not be empty");
@@ -1120,7 +1132,7 @@ describe("TMU top-level surface smoke", () => {
11201132
await terminal.stdin.write("\r");
11211133
await waitFor(() => terminal.lastFrame()!.includes("Error: cache is read-only"));
11221134
expect(coordinator.uiState.renameDialog?.value).toBe("Cannot Save");
1123-
expect(coordinator.appState.queue.entries[0]?.track.title).toBe("Clear Name");
1135+
expect(coordinator.appState.queue.entries[0]?.track.title).toBe("AB");
11241136
await terminal.stdin.write("\x1b");
11251137
expect(coordinator.uiState.renameDialog).toBeNull();
11261138
});

tests/youtube-cache-provider.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ describe("YouTubeCacheProvider", () => {
8787
expect(provider.searchTracks("bad download")).toEqual([]);
8888
expect(JSON.parse(await readFile(join(dir, "rename00001.json"), "utf8"))).toMatchObject({
8989
title: "Bad Download Name",
90-
customTitle: "Clear Track Name",
90+
trackTitleOverride: "Clear Track Name",
9191
mediaFileName: "rename00001.opus",
9292
});
9393

0 commit comments

Comments
 (0)