@brftech/filex-core 0.31.0 → 0.32.0

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.
@@ -41,6 +41,15 @@ const props = defineProps<{
41
41
  globalSearch?: (q: string) => Promise<GlobalSearchHit[]>;
42
42
  /* wiring:d1 — false hides the split command (narrow embeds). */
43
43
  splitEnabled?: boolean;
44
+ /**
45
+ * surucu:d1 — the query this palette opens WITH.
46
+ *
47
+ * The drive shell's header field searches the folder you are in; ⌘K from
48
+ * inside it opens this palette carrying the same words, so "not in this
49
+ * folder — try everywhere" costs no retyping. Empty (the default) is the
50
+ * behaviour every other caller has had: a blank palette.
51
+ */
52
+ initialQuery?: string;
44
53
  }>();
45
54
 
46
55
  const emit = defineEmits<{
@@ -367,14 +376,21 @@ watch(
367
376
  () => props.open,
368
377
  (v) => {
369
378
  if (v) {
370
- query.value = '';
379
+ // Read at OPEN, not watched: a seed that kept overwriting the field
380
+ // would undo every keystroke of whoever is typing here now.
381
+ query.value = props.initialQuery ?? '';
371
382
  active.value = 0;
372
383
  /* bul:s3 — fresh session state for the new groups. */
373
384
  everywhere.value = [];
374
385
  everywhereLoading.value = false;
375
386
  savedSearches.value = readSavedSearches();
376
387
  document.addEventListener('keydown', onDocKeydown, true);
377
- void nextTick(() => inputEl.value?.focus());
388
+ void nextTick(() => {
389
+ inputEl.value?.focus();
390
+ // Selected, not appended to: the seed is a starting point, and the
391
+ // next keystroke should be able to replace it outright.
392
+ inputEl.value?.select();
393
+ });
378
394
  } else {
379
395
  if (everywhereTimer) clearTimeout(everywhereTimer); /* bul:s3 */
380
396
  document.removeEventListener('keydown', onDocKeydown, true);
@@ -19,6 +19,7 @@ import { computed, ref, watch } from 'vue';
19
19
  import type { LocaleCode } from '../types/ExplorerConfig';
20
20
  import { useLocale } from '../composables/useLocale';
21
21
  import { parseRecoveryKey } from '../lib/e2ecrypto';
22
+ import type { EscrowAvailability } from '../lib/e2ecrypto';
22
23
  import Modal from '../modals/Modal.vue';
23
24
 
24
25
  const props = defineProps<{
@@ -26,9 +27,15 @@ const props = defineProps<{
26
27
  locale: LocaleCode;
27
28
  /** The folder has a user recovery key slot (v2 markers created since 0.31). */
28
29
  hasRecovery: boolean;
29
- /** The folder has an escrow slot AND this installation has escrow enabled. */
30
- hasEscrow: boolean;
31
- /** Short id of the escrow key the folder was sealed to. */
30
+ /**
31
+ * Whether the escrow door applies to THIS folder — see escrowAvailability.
32
+ * 'predates' and 'other-key' are not "no escrow tab"; they are two
33
+ * different facts the dialog has to state, because an admin who knows the
34
+ * installation has escrow reads a missing tab as a bug and tries the key
35
+ * anyway.
36
+ */
37
+ escrowState: EscrowAvailability;
38
+ /** Short id of the escrow key THIS FOLDER was sealed to, when it has one. */
32
39
  escrowKid?: string | null;
33
40
  busy?: boolean;
34
41
  /** Set by the parent after a failed attempt. */
@@ -41,6 +48,16 @@ const emit = defineEmits<{
41
48
  }>();
42
49
 
43
50
  const { t } = useLocale(() => props.locale);
51
+ /** The escrow tab is offered only when the key can actually open this
52
+ * folder. Every other state is explained in words instead. */
53
+ const hasEscrow = computed(() => props.escrowState === 'available');
54
+ /** The installation has an escrow key that does NOT apply here. Saying so
55
+ * is the whole point: silence reads as "not enabled". */
56
+ const escrowUnavailableReason = computed(() => {
57
+ if (props.escrowState === 'predates') return t('e2e.recover.escrow_predates');
58
+ if (props.escrowState === 'other-key') return t('e2e.recover.escrow_other_key');
59
+ return null;
60
+ });
44
61
  const mode = ref<'recovery' | 'escrow'>('recovery');
45
62
  const recoveryValue = ref('');
46
63
  const escrowValue = ref('');
@@ -60,7 +77,7 @@ watch(
60
77
 
61
78
  /** A folder with neither slot is a pre-0.31 folder: the password is the
62
79
  * only way in, and saying so is more useful than an empty dialog. */
63
- const nothingAvailable = computed(() => !props.hasRecovery && !props.hasEscrow);
80
+ const nothingAvailable = computed(() => !props.hasRecovery && !hasEscrow.value);
64
81
 
65
82
  const shownError = computed(() => localErr.value || props.error || null);
66
83
 
@@ -149,6 +166,14 @@ function submit() {
149
166
  </form>
150
167
  </template>
151
168
 
169
+ <!-- ⚠ Shown whenever the installation has an escrow key that cannot
170
+ open THIS folder. A folder created before escrow was adopted must
171
+ not merely lack a tab; it must say why, or the absence reads as a
172
+ bug and the operator burns an afternoon on it. -->
173
+ <p v-if="escrowUnavailableReason" class="fe-e2e-recover__note">
174
+ {{ escrowUnavailableReason }}
175
+ </p>
176
+
152
177
  <p v-if="shownError" class="fe-form__error">{{ shownError }}</p>
153
178
  </div>
154
179
  <template #actions>
@@ -0,0 +1,244 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * FilterBar — surucu:d1, the chip row under the breadcrumb in the `drive`
4
+ * profile.
5
+ *
6
+ * Three chips: Type · Modified · Size. Each opens a small single-choice
7
+ * popover; a chip with a choice made carries that choice as its label, so the
8
+ * row says what is being filtered without opening anything.
9
+ *
10
+ * ⚠ THREE, not the four the mockup draws. There is no **People** chip because
11
+ * there is nothing behind one: a listing row carries no owner
12
+ * (`projectFileNodes` emits id/path/basename/type/extension/size/mime_type/
13
+ * storage/etag/perm/thumb_url/last_modified and nothing else), `nodes.owner_id`
14
+ * is quota bookkeeping that is nil for everything a sync discovered, and the
15
+ * listing endpoint reads no owner parameter. A People chip would be a control
16
+ * that opens, offers names, and changes nothing — see docs and the report on
17
+ * this branch; the advanced-search round owns that question.
18
+ *
19
+ * ⚠ Popovers are TELEPORTED to <body>, like ContextMenu, and positioned
20
+ * `fixed`. An absolutely-positioned panel inside the explorer is clipped by
21
+ * `.fe__body`'s own scroll container, which is how a menu ends up half visible
22
+ * with its own scrollbar.
23
+ *
24
+ * ⚠ No `<style>` block. Package CSS lives in `styles/base.css` — a scoped block
25
+ * compiles to `.cls[data-v-HASH]` and the hash does not match in the
26
+ * web-component build, so the rules silently stop applying in every embed.
27
+ */
28
+ import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue';
29
+ import { useLocale } from '../composables/useLocale';
30
+ import type { LocaleCode, ThemeMode } from '../types/ExplorerConfig';
31
+ import type { DriveFilters, ModifiedFilter, SizeFilter, TypeFilter } from '../lib/fileFilters';
32
+ import { activeFilterCount } from '../lib/fileFilters';
33
+
34
+ const props = defineProps<{
35
+ value: DriveFilters;
36
+ locale: LocaleCode;
37
+ /** Resolved theme — the teleported popover leaves the `.fe` variable scope. */
38
+ theme?: ThemeMode;
39
+ /** Rows currently shown / rows the folder holds, for the count chip. */
40
+ shown?: number;
41
+ total?: number;
42
+ }>();
43
+
44
+ const emit = defineEmits<{ (e: 'update:value', v: DriveFilters): void }>();
45
+
46
+ const { t } = useLocale(() => props.locale);
47
+
48
+ type Group = 'type' | 'modified' | 'size';
49
+
50
+ const TYPE_OPTIONS: TypeFilter[] = [
51
+ 'any', 'folder', 'document', 'spreadsheet', 'presentation', 'pdf',
52
+ 'image', 'video', 'audio', 'archive', 'code',
53
+ ];
54
+ const MODIFIED_OPTIONS: ModifiedFilter[] = ['any', 'today', '7d', '30d', 'year'];
55
+ const SIZE_OPTIONS: SizeFilter[] = ['any', 'lt1', '1to10', '10to100', 'gt100'];
56
+
57
+ function optionsFor(g: Group): string[] {
58
+ if (g === 'type') return TYPE_OPTIONS;
59
+ if (g === 'modified') return MODIFIED_OPTIONS;
60
+ return SIZE_OPTIONS;
61
+ }
62
+
63
+ function optionLabel(g: Group, v: string): string {
64
+ return t(`filter.${g}.${v}`);
65
+ }
66
+
67
+ /** The chip's own label: the group name until a choice is made, then the
68
+ * choice — a row of three identical words tells the reader nothing. */
69
+ function chipLabel(g: Group): string {
70
+ const v = props.value[g];
71
+ return v === 'any' ? t(`filter.${g}`) : optionLabel(g, v);
72
+ }
73
+
74
+ const activeCount = computed(() => activeFilterCount(props.value));
75
+
76
+ // ── the popover ──────────────────────────────────────────────────────
77
+ const openGroup = ref<Group | null>(null);
78
+ const pos = ref({ x: 0, y: 0 });
79
+ const panelEl = ref<HTMLElement | null>(null);
80
+ const chipEls = ref<Record<string, HTMLElement | null>>({});
81
+
82
+ function setChipEl(g: Group, el: unknown) {
83
+ chipEls.value[g] = (el as HTMLElement | null) ?? null;
84
+ }
85
+
86
+ // The open popover's data, precomputed. Reading `value[openGroup]` straight
87
+ // from the template would lean on template narrowing across a Teleport, and
88
+ // `vue-tsc --noEmit` is part of this package's build.
89
+ const popOptions = computed<string[]>(() => (openGroup.value ? optionsFor(openGroup.value) : []));
90
+ const popTitle = computed(() => (openGroup.value ? t(`filter.${openGroup.value}`) : ''));
91
+ function popChecked(opt: string): boolean {
92
+ return !!openGroup.value && props.value[openGroup.value] === opt;
93
+ }
94
+ function popLabel(opt: string): string {
95
+ return openGroup.value ? optionLabel(openGroup.value, opt) : opt;
96
+ }
97
+ function popPick(opt: string): void {
98
+ if (openGroup.value) pick(openGroup.value, opt);
99
+ }
100
+
101
+ async function toggle(g: Group) {
102
+ if (openGroup.value === g) {
103
+ close();
104
+ return;
105
+ }
106
+ const r = chipEls.value[g]?.getBoundingClientRect();
107
+ pos.value = { x: r ? r.left : 8, y: r ? r.bottom + 6 : 8 };
108
+ openGroup.value = g;
109
+ await nextTick();
110
+ // Keep it on screen: a chip near the right edge would otherwise open a panel
111
+ // that runs off it, and the row is right beside the info panel.
112
+ const panel = panelEl.value;
113
+ if (panel) {
114
+ const box = panel.getBoundingClientRect();
115
+ if (box.right > window.innerWidth - 8) {
116
+ pos.value = { ...pos.value, x: Math.max(8, window.innerWidth - 8 - box.width) };
117
+ }
118
+ if (box.bottom > window.innerHeight - 8) {
119
+ const r2 = chipEls.value[g]?.getBoundingClientRect();
120
+ pos.value = { ...pos.value, y: Math.max(8, (r2 ? r2.top : 0) - box.height - 6) };
121
+ }
122
+ panel.querySelector<HTMLElement>('[data-checked="true"]')?.focus();
123
+ }
124
+ }
125
+
126
+ function close(restoreFocus = false) {
127
+ const g = openGroup.value;
128
+ openGroup.value = null;
129
+ if (restoreFocus && g) chipEls.value[g]?.focus();
130
+ }
131
+
132
+ function pick(g: Group, v: string) {
133
+ emit('update:value', { ...props.value, [g]: v } as DriveFilters);
134
+ close(true);
135
+ }
136
+
137
+ function clearAll() {
138
+ emit('update:value', { type: 'any', modified: 'any', size: 'any' });
139
+ }
140
+
141
+ function onDocPointer(ev: PointerEvent) {
142
+ if (!openGroup.value) return;
143
+ const el = ev.target as Node;
144
+ if (panelEl.value?.contains(el)) return;
145
+ if (Object.values(chipEls.value).some((c) => c?.contains(el))) return;
146
+ close();
147
+ }
148
+
149
+ function onKey(ev: KeyboardEvent) {
150
+ if (ev.key === 'Escape' && openGroup.value) {
151
+ ev.stopPropagation();
152
+ close(true);
153
+ }
154
+ }
155
+
156
+ onMounted(() => {
157
+ document.addEventListener('pointerdown', onDocPointer, true);
158
+ document.addEventListener('keydown', onKey, true);
159
+ });
160
+ onBeforeUnmount(() => {
161
+ document.removeEventListener('pointerdown', onDocPointer, true);
162
+ document.removeEventListener('keydown', onKey, true);
163
+ });
164
+ </script>
165
+
166
+ <template>
167
+ <div class="fe-filterbar" role="group" :aria-label="t('filter.aria')" data-testid="filterbar">
168
+ <button
169
+ v-for="g in (['type', 'modified', 'size'] as Group[])"
170
+ :key="g"
171
+ :ref="(el) => setChipEl(g, el)"
172
+ type="button"
173
+ class="fe-filterbar__chip"
174
+ :class="{ 'is-set': value[g] !== 'any', 'is-open': openGroup === g }"
175
+ :aria-expanded="openGroup === g"
176
+ aria-haspopup="listbox"
177
+ :data-testid="`filter-${g}`"
178
+ @click="toggle(g)"
179
+ >
180
+ <span class="fe-filterbar__label">{{ chipLabel(g) }}</span>
181
+ <svg
182
+ class="fe-ficon fe-filterbar__caret"
183
+ viewBox="0 0 24 24"
184
+ fill="none"
185
+ stroke="currentColor"
186
+ stroke-width="2"
187
+ stroke-linecap="round"
188
+ stroke-linejoin="round"
189
+ aria-hidden="true"
190
+ focusable="false"
191
+ >
192
+ <path d="M7 10l5 5 5-5" />
193
+ </svg>
194
+ </button>
195
+
196
+ <button
197
+ v-if="activeCount > 0"
198
+ type="button"
199
+ class="fe-filterbar__clear"
200
+ data-testid="filter-clear"
201
+ @click="clearAll"
202
+ >
203
+ {{ t('filter.clear') }}
204
+ </button>
205
+
206
+ <span
207
+ v-if="activeCount > 0 && typeof shown === 'number' && typeof total === 'number'"
208
+ class="fe-filterbar__count"
209
+ role="status"
210
+ data-testid="filter-count"
211
+ >{{ t('filter.count', { shown: String(shown), total: String(total) }) }}</span
212
+ >
213
+
214
+ <Teleport to="body">
215
+ <div
216
+ v-if="openGroup"
217
+ ref="panelEl"
218
+ class="fe-filterpop"
219
+ :class="{
220
+ 'fe--theme-light': theme === 'light',
221
+ 'fe--theme-dark': theme === 'dark',
222
+ }"
223
+ role="listbox"
224
+ :aria-label="popTitle"
225
+ :style="{ left: pos.x + 'px', top: pos.y + 'px' }"
226
+ >
227
+ <button
228
+ v-for="opt in popOptions"
229
+ :key="opt"
230
+ type="button"
231
+ class="fe-filterpop__item"
232
+ role="option"
233
+ :aria-selected="popChecked(opt)"
234
+ :data-checked="popChecked(opt) ? 'true' : 'false'"
235
+ :data-testid="`filter-opt-${opt}`"
236
+ @click="popPick(opt)"
237
+ >
238
+ <span class="fe-filterpop__tick" aria-hidden="true">{{ popChecked(opt) ? '✓' : '' }}</span>
239
+ <span>{{ popLabel(opt) }}</span>
240
+ </button>
241
+ </div>
242
+ </Teleport>
243
+ </div>
244
+ </template>
@@ -38,6 +38,15 @@ const props = defineProps<{
38
38
  * own screenshots show — had a view they could not fill.
39
39
  */
40
40
  starredIds?: Set<number>;
41
+ /**
42
+ * Offer the star affordance at all. Follows the Starred view: when the panel
43
+ * leaves that view out — a shared app token has no single person behind it,
44
+ * so "your starred files" is one list shown to strangers — offering to star
45
+ * something is offering to write into that same shared list. Owner's call,
46
+ * 2026-09-05: "yıldızlı yeri gözükmüyorsa o zaman yıldızla/yıldızı kaldır da
47
+ * gözükmemeli".
48
+ */
49
+ starEnabled?: boolean;
41
50
  apiBase?: string;
42
51
  authHeaders?: () => Record<string, string> | Promise<Record<string, string>>;
43
52
  authCredentials?: RequestCredentials;
@@ -61,6 +70,7 @@ function thumbOf(n: FileNode): string | null {
61
70
  /** A card carries a star when the host wired the API and the node is a file
62
71
  * with a server id — the same rule the list row uses. */
63
72
  function canStar(n: FileNode): boolean {
73
+ if (props.starEnabled === false) return false;
64
74
  return props.apiBase !== undefined && typeof n.id === 'number' && n.type === 'file';
65
75
  }
66
76
 
@@ -191,6 +201,7 @@ function metaFor(n: FileNode): string {
191
201
  role="option"
192
202
  :aria-selected="isSelected(n) ? 'true' : 'false'"
193
203
  :aria-label="nodeDisplayName(n)"
204
+ :data-fe-path="n.path /* wiring:d1 - middle-click new-tab delegation */"
194
205
  draggable="true"
195
206
  @click="onClick(n, $event)"
196
207
  @dblclick="onDbl(n)"
@@ -2,7 +2,7 @@
2
2
  /**
3
3
  * GridView — card grid. Thumbnails preferred, fall back to icon.
4
4
  */
5
- import { ref } from 'vue'; /* wiring:c4 */
5
+ import { computed, ref } from 'vue'; /* wiring:c4 */
6
6
  import type { FileNode } from '../types/FileNode';
7
7
  import { hasInternalDrag } from '../lib/dragOut';
8
8
  import type { LocaleCode } from '../types/ExplorerConfig';
@@ -36,9 +36,30 @@ const props = defineProps<{
36
36
  * own screenshots show — had a view they could not fill.
37
37
  */
38
38
  starredIds?: Set<number>;
39
+ /**
40
+ * Offer the star affordance at all. Follows the Starred view: when the panel
41
+ * leaves that view out — a shared app token has no single person behind it,
42
+ * so "your starred files" is one list shown to strangers — offering to star
43
+ * something is offering to write into that same shared list. Owner's call,
44
+ * 2026-09-05: "yıldızlı yeri gözükmüyorsa o zaman yıldızla/yıldızı kaldır da
45
+ * gözükmemeli".
46
+ */
47
+ starEnabled?: boolean;
39
48
  apiBase?: string;
40
49
  authHeaders?: () => Record<string, string> | Promise<Record<string, string>>;
41
50
  authCredentials?: RequestCredentials;
51
+ /**
52
+ * surucu:d1 — draw "Folders" and "Files" as labelled sections
53
+ * (`uiProfile: 'drive'`). Absent/false renders the flat grid unchanged, down
54
+ * to the DOM: the two headings are the only extra nodes, and without this
55
+ * prop none are emitted.
56
+ *
57
+ * The rows are re-ordered HERE (directories first, stable within each group)
58
+ * rather than trusted to arrive that way — the listing endpoint promises no
59
+ * grouping, and a "Folders" heading with a spreadsheet under it is worse
60
+ * than no heading at all.
61
+ */
62
+ sections?: boolean;
42
63
  }>();
43
64
 
44
65
  const emit = defineEmits<{
@@ -50,6 +71,23 @@ const emit = defineEmits<{
50
71
  (e: 'star-change', node: FileNode, value: boolean): void;
51
72
  }>();
52
73
 
74
+ /* surucu:d1 — grouped order + the two headings. `sort` is stable, so within
75
+ * folders and within files the listing's own order survives untouched. */
76
+ const ordered = computed<FileNode[]>(() =>
77
+ props.sections
78
+ ? [...props.files].sort((a, b) => (a.type === 'dir' ? 0 : 1) - (b.type === 'dir' ? 0 : 1))
79
+ : props.files,
80
+ );
81
+ const firstDirPath = computed(() => ordered.value.find((f) => f.type === 'dir')?.path ?? null);
82
+ const firstFilePath = computed(() => ordered.value.find((f) => f.type !== 'dir')?.path ?? null);
83
+
84
+ function headingBefore(n: FileNode): string | null {
85
+ if (!props.sections) return null;
86
+ if (n.path === firstDirPath.value) return t('drive.section.folders');
87
+ if (n.path === firstFilePath.value) return t('drive.section.files');
88
+ return null;
89
+ }
90
+
53
91
  const { t, formatSize, nodeDisplayName } = useLocale(() => props.locale);
54
92
 
55
93
  // Prefer the authenticated resolver when the host wired one; otherwise fall
@@ -61,6 +99,7 @@ function thumbOf(n: FileNode): string | null {
61
99
  /** A card carries a star when the host wired the API and the node is a file
62
100
  * with a server id — the same rule the list row uses. */
63
101
  function canStar(n: FileNode): boolean {
102
+ if (props.starEnabled === false) return false;
64
103
  return props.apiBase !== undefined && typeof n.id === 'number' && n.type === 'file';
65
104
  }
66
105
 
@@ -189,9 +228,12 @@ function snippetTitle(snippet: string): string {
189
228
  :aria-label="t('grid.aria')"
190
229
  :aria-busy="loading ? 'true' : undefined"
191
230
  >
231
+ <template v-for="n in ordered" :key="n.path">
232
+ <!-- surucu:d1 — the section label: a grid item spanning every column. The
233
+ cards keep `role="option"`; the heading is aria-hidden, and the group
234
+ it names is already in each card's own label. -->
235
+ <p v-if="headingBefore(n)" class="fe-grid__heading" aria-hidden="true">{{ headingBefore(n) }}</p>
192
236
  <div
193
- v-for="n in files"
194
- :key="n.path"
195
237
  class="fe-grid__card"
196
238
  :class="{
197
239
  'is-selected': isSelected(n),
@@ -279,6 +321,7 @@ function snippetTitle(snippet: string): string {
279
321
  </template>
280
322
  </div>
281
323
  </div>
324
+ </template>
282
325
  <div v-if="!loading && files.length === 0" class="fe-grid__empty">
283
326
  {{ t('empty.folder') }}
284
327
  </div>