ink-framework-files 0.0.0-stage → 0.1.0-alpha.10

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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Vandam Dinh
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,14 @@
1
+ {
2
+ "version": 1,
3
+ "modules": {
4
+ "src/index.ts": [
5
+ "files"
6
+ ],
7
+ "src/images.ts": [
8
+ "file-images"
9
+ ],
10
+ "src/media.ts": [
11
+ "media-library"
12
+ ]
13
+ }
14
+ }
package/package.json CHANGED
@@ -1,6 +1,27 @@
1
1
  {
2
2
  "name": "ink-framework-files",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0-alpha.10",
4
+ "exports": {
5
+ ".": "./src/index.ts",
6
+ "./images": "./src/images.ts",
7
+ "./media": "./src/media.ts"
8
+ },
9
+ "peerDependencies": {
10
+ "ink": "npm:ink-framework@0.1.0-alpha.10",
11
+ "react": "19.2.8"
12
+ },
13
+ "dependencies": {
14
+ "@ink/lightos": "npm:ink-framework-lightos@0.1.0-alpha.10"
15
+ },
16
+ "license": "MIT",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/No-Scrolling/ink.git",
20
+ "directory": "packages/files"
21
+ },
22
+ "files": [
23
+ "src",
24
+ "*.json",
25
+ "*.ts"
26
+ ]
27
+ }
package/src/decode.ts ADDED
@@ -0,0 +1,16 @@
1
+ import { NativeError } from "ink/native";
2
+ import type { FileRef } from "./index";
3
+
4
+ export function decodeFileRef(value: string): FileRef | null {
5
+ const file: unknown = JSON.parse(value);
6
+ if (file === null) return null;
7
+ if (typeof file !== "object" || !["id", "src", "name", "mimeType"].every(key => typeof Reflect.get(file, key) === "string")
8
+ || typeof Reflect.get(file, "size") !== "number" || !Number.isSafeInteger(Reflect.get(file, "size")) || Reflect.get(file, "size") < 0) {
9
+ throw new NativeError("protocol", "Invalid managed file");
10
+ }
11
+ for (const key of ["width", "height", "duration"]) {
12
+ const number: unknown = Reflect.get(file, key);
13
+ if (number !== undefined && (typeof number !== "number" || !Number.isFinite(number) || number < 0)) throw new NativeError("protocol", "Invalid media metadata");
14
+ }
15
+ return file as FileRef;
16
+ }
package/src/images.ts ADDED
@@ -0,0 +1,10 @@
1
+ import { callNative, NativeError } from "ink/native";
2
+ import { decodeFileRef } from "./decode";
3
+ import type { FileRef } from "./index";
4
+
5
+ export async function prepareImage(file: FileRef, options: { maxWidth: number; maxHeight: number }): Promise<FileRef> {
6
+ for (const size of [options.maxWidth, options.maxHeight]) if (!Number.isSafeInteger(size) || size < 1 || size > 4096) throw new RangeError("Image bounds must be whole numbers between 1 and 4096");
7
+ const result = decodeFileRef(await callNative("files", "prepare-image", { id: file.id, ...options }, { timeoutMs: 120_000 }));
8
+ if (!result) throw new NativeError("protocol", "Image preparation returned no file");
9
+ return result;
10
+ }
package/src/index.ts ADDED
@@ -0,0 +1,23 @@
1
+ import { callNative } from "ink/native";
2
+ import { decodeFileRef } from "./decode";
3
+
4
+ export interface FileRef {
5
+ id: string;
6
+ src: string;
7
+ name: string;
8
+ mimeType: string;
9
+ size: number;
10
+ width?: number;
11
+ height?: number;
12
+ duration?: number;
13
+ }
14
+
15
+ export const files = {
16
+ async pick(options: { types?: readonly string[]; signal?: AbortSignal } = {}) {
17
+ return decodeFileRef(await callNative("files", "pick", { types: options.types }, { timeoutMs: 600_000, signal: options.signal }));
18
+ },
19
+ open: async (id: string) => decodeFileRef(await callNative("files", "open", { id })),
20
+ remove: async (id: string): Promise<void> => { await callNative("files", "remove", { id }); },
21
+ save: async (file: FileRef): Promise<void> => { await callNative("files", "save", { id: file.id }, { timeoutMs: 600_000 }); },
22
+ share: async (file: FileRef): Promise<void> => { await callNative("files", "share", { id: file.id }); },
23
+ };
package/src/media.ts ADDED
@@ -0,0 +1,153 @@
1
+ import { lightos, type PermissionStatus } from "@ink/lightos";
2
+ import { createElement, useCallback, useEffect, useMemo, useRef, useState } from "react";
3
+ import { EmptyState, ErrorState, List, LoadingState, Screen, Text } from "ink";
4
+ import { nativeListRow, nativeListTemplate } from "ink/internal/list";
5
+ import { callNative, NativeError } from "ink/native";
6
+ import { files, type FileRef } from "./index";
7
+ import { decodeFileRef } from "./decode";
8
+ import { check, checkCircleFilled, videoFileFilled } from "ink/icons";
9
+
10
+ const mediaTemplate = nativeListTemplate((node, field) => node("MediaGridRow", {},
11
+ Array.from({ length: 3 }, (_, index) => node("MediaCell", {
12
+ hidden: field(index, "hidden"), src: field(index, "src"), selected: field(index, "selected"),
13
+ video: field(index, "video"), checkIcon: checkCircleFilled, videoIcon: videoFileFilled,
14
+ onPress: field(index, "onPress"),
15
+ }))));
16
+
17
+ type MediaItem = { id: string; src: string; name: string; mimeType: string; width: number; height: number };
18
+ type MediaPage = { items: MediaItem[]; nextCursor: string | null };
19
+ export type MediaPickerProps = {
20
+ kind?: "image" | "video" | "all";
21
+ title?: string;
22
+ onSelect: (files: FileRef[]) => void | Promise<void>;
23
+ };
24
+
25
+ function decodePage(value: string): MediaPage {
26
+ const page: unknown = JSON.parse(value);
27
+ if (typeof page !== "object" || page === null || !("items" in page) || !Array.isArray(page.items)
28
+ || !("nextCursor" in page) || (page.nextCursor !== null && typeof page.nextCursor !== "string")) throw new NativeError("protocol", "Invalid media page");
29
+ for (const item of page.items) {
30
+ if (typeof item !== "object" || item === null || !["id", "src", "name", "mimeType"].every(key => typeof Reflect.get(item, key) === "string")
31
+ || !["width", "height"].every(key => typeof Reflect.get(item, key) === "number")) throw new NativeError("protocol", "Invalid media item");
32
+ }
33
+ return page as MediaPage;
34
+ }
35
+
36
+ export function MediaPicker({ kind = "all", title = kind === "image" ? "Photos" : kind === "video" ? "Videos" : "Photos and videos", onSelect }: MediaPickerProps) {
37
+ const [items, setItems] = useState<MediaItem[]>([]);
38
+ const [selected, setSelected] = useState<Set<string>>(new Set());
39
+ const [permission, setPermission] = useState<PermissionStatus | null>(null);
40
+ const [loading, setLoading] = useState(true);
41
+ const [error, setError] = useState<Error | null>(null);
42
+ const [importing, setImporting] = useState(false);
43
+ const [hasMore, setHasMore] = useState(false);
44
+ const cursor = useRef<string | null>(null);
45
+ const nextPage = useRef<Promise<MediaPage> | null>(null);
46
+ const active = useRef<AbortController | null>(null);
47
+ const loadingPage = useRef<AbortSignal | null>(null);
48
+ const confirming = useRef<AbortSignal | null>(null);
49
+
50
+ const loadPage = useCallback(async (signal: AbortSignal, first = false) => {
51
+ if (loadingPage.current || signal.aborted) return;
52
+ loadingPage.current = signal;
53
+ const prefetched = first ? null : nextPage.current;
54
+ nextPage.current = null;
55
+ try {
56
+ const page = await prefetched
57
+ ?? decodePage(await callNative("files", "media-list", { kind, cursor: first ? undefined : cursor.current, limit: 60 }, { signal }));
58
+ if (signal.aborted) return;
59
+ if (!first && page.nextCursor !== null && page.nextCursor === cursor.current) throw new NativeError("protocol", "Media pagination did not advance");
60
+ setItems(previous => {
61
+ const seen = new Set(first ? [] : previous.map(item => item.id));
62
+ return [...(first ? [] : previous), ...page.items.filter(item => { if (seen.has(item.id)) return false; seen.add(item.id); return true; })];
63
+ });
64
+ cursor.current = page.nextCursor;
65
+ setHasMore(page.nextCursor !== null);
66
+ if (page.nextCursor !== null) {
67
+ nextPage.current = callNative("files", "media-list", { kind, cursor: page.nextCursor, limit: 60 }, { signal }).then(decodePage);
68
+ // The next load awaits this promise and exposes any failure through List's retry action.
69
+ void nextPage.current.catch(() => {});
70
+ }
71
+ } finally { if (loadingPage.current === signal) loadingPage.current = null; }
72
+ }, [kind]);
73
+
74
+ const refresh = useCallback(async () => {
75
+ active.current?.abort();
76
+ nextPage.current = null;
77
+ const controller = new AbortController(); active.current = controller;
78
+ loadingPage.current = null;
79
+ setLoading(true); setError(null);
80
+ try {
81
+ const name = kind === "image" ? "photos" : kind === "video" ? "videos" : "photos-and-videos";
82
+ const result = await lightos.getPermission(name, { signal: controller.signal });
83
+ if (controller.signal.aborted) return;
84
+ setPermission(result);
85
+ if (result === "granted") await loadPage(controller.signal, true);
86
+ } catch (reason) {
87
+ if (!controller.signal.aborted) setError(reason instanceof Error ? reason : new Error(String(reason)));
88
+ } finally { if (!controller.signal.aborted) setLoading(false); }
89
+ }, [kind, loadPage]);
90
+
91
+ useEffect(() => {
92
+ setSelected(new Set()); setItems([]); cursor.current = null;
93
+ confirming.current = null; setImporting(false);
94
+ void refresh();
95
+ return () => { active.current?.abort(); active.current = null; };
96
+ }, [refresh]);
97
+
98
+ const confirm = async () => {
99
+ if (confirming.current || !selected.size || !active.current) return;
100
+ const signal = active.current.signal;
101
+ confirming.current = signal; setImporting(true); setError(null);
102
+ const imported: FileRef[] = [];
103
+ let delivered = false;
104
+ try {
105
+ for (const item of items.filter(item => selected.has(item.id))) {
106
+ const file = decodeFileRef(await callNative("files", "media-import", { id: item.id }, { signal, timeoutMs: 600_000 }));
107
+ if (!file) throw new NativeError("protocol", "Media import returned no file");
108
+ imported.push(file);
109
+ }
110
+ signal.throwIfAborted();
111
+ delivered = true;
112
+ await onSelect(imported);
113
+ } catch (reason) {
114
+ if (!signal.aborted) setError(reason instanceof Error ? reason : new Error(String(reason)));
115
+ } finally {
116
+ if (!delivered) await Promise.all(imported.map(file => files.remove(file.id).catch(() => {})));
117
+ if (confirming.current === signal) {
118
+ confirming.current = null;
119
+ if (!signal.aborted) setImporting(false);
120
+ }
121
+ }
122
+ };
123
+
124
+ const rows = useMemo(() => {
125
+ const rows: MediaItem[][] = [];
126
+ for (let index = 0; index < items.length; index += 3) rows.push(items.slice(index, index + 3));
127
+ return rows;
128
+ }, [items]);
129
+ if (loading) return createElement(Screen, { title }, createElement(LoadingState, { label: "Loading library…" }));
130
+ if (error) return createElement(Screen, { title }, createElement(ErrorState, {
131
+ message: error.message,
132
+ onRetry: () => { if (permission !== "granted" || items.length === 0) void refresh(); else setError(null); },
133
+ }));
134
+ if (permission !== "granted") return createElement(Screen, { title },
135
+ createElement(Text, { size: 18 }, `Access to all ${kind === "image" ? "photos" : kind === "video" ? "videos" : "photos and videos"} is required to browse the library.`));
136
+ if (importing) return createElement(Screen, { title }, createElement(LoadingState, { label: "Preparing attachments…" }));
137
+ return createElement("MediaPickerScreen", { title, rightIcon: selected.size ? check : undefined, onRightPress: selected.size ? confirm : undefined },
138
+ items.length === 0 ? createElement(EmptyState, { title: kind === "video" ? "No videos" : kind === "image" ? "No photos" : "No photos or videos" }) :
139
+ createElement(List<MediaItem[]>, {
140
+ items: rows, keyExtractor: row => row[0].id, gap: 0, hasMore,
141
+ onLoadMore: async () => { if (active.current) await loadPage(active.current.signal); },
142
+ renderItem: nativeListRow((row: MediaItem[]) => createElement("MediaGridRow", null, row.map(item => createElement("MediaCell", {
143
+ key: item.id, src: item.src, selected: selected.has(item.id), video: item.mimeType.startsWith("video/"), checkIcon: checkCircleFilled, videoIcon: videoFileFilled,
144
+ onPress: () => setSelected(previous => { const next = new Set(previous); if (next.has(item.id)) next.delete(item.id); else next.add(item.id); return next; }),
145
+ }))), row => [Array.from({ length: 3 }, (_, index) => {
146
+ const item = row[index];
147
+ return { hidden: !item, src: item?.src ?? row[0].src, selected: !!item && selected.has(item.id),
148
+ video: !!item?.mimeType.startsWith("video/"), onPress: item && (() => setSelected(previous => {
149
+ const next = new Set(previous); if (next.has(item.id)) next.delete(item.id); else next.add(item.id); return next;
150
+ })) };
151
+ })], mediaTemplate),
152
+ }));
153
+ }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.