@brftech/filex-core 0.18.2 → 0.20.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.
Files changed (39) hide show
  1. package/README.md +34 -4
  2. package/dist/filex-core.js +10626 -6501
  3. package/dist/filex-core.js.map +1 -1
  4. package/dist/filex-core.umd.cjs +94 -63
  5. package/dist/filex-core.umd.cjs.map +1 -1
  6. package/dist/index.d.ts +1026 -7
  7. package/dist/style.css +1 -1
  8. package/package.json +3 -3
  9. package/src/FileExplorer.vue +103 -68
  10. package/src/components/ConnectionGuideView.vue +333 -0
  11. package/src/components/ConnectionsPanel.vue +912 -0
  12. package/src/components/NFSExportsPanel.vue +283 -0
  13. package/src/components/S3KeysPanel.vue +381 -0
  14. package/src/components/SSHKeysPanel.vue +222 -0
  15. package/src/components/StorageFields.vue +362 -0
  16. package/src/components/TokensPanel.vue +191 -0
  17. package/src/components/UploadProgress.vue +5 -1
  18. package/src/composables/useConnections.ts +271 -0
  19. package/src/composables/useFileApi.ts +15 -2
  20. package/src/composables/useNFSExports.ts +148 -0
  21. package/src/composables/useS3Keys.ts +175 -0
  22. package/src/composables/useSSHKeys.ts +119 -0
  23. package/src/composables/useThumbs.ts +1 -1
  24. package/src/composables/useTokens.ts +121 -0
  25. package/src/composables/useUploadChunked.ts +433 -164
  26. package/src/index.ts +77 -2
  27. package/src/lib/connectionGuides.ts +1279 -0
  28. package/src/lib/realtime.ts +1 -1
  29. package/src/lib/uploadResume.ts +157 -0
  30. package/src/locales/en.ts +413 -0
  31. package/src/locales/tr.ts +416 -0
  32. package/src/modals/ConvertModal.vue +1 -1
  33. package/src/styles/base.css +12 -12
  34. package/src/types/Connections.ts +122 -0
  35. package/src/types/ExplorerConfig.ts +23 -2
  36. package/src/types/NFSExports.ts +47 -0
  37. package/src/types/S3Keys.ts +55 -0
  38. package/src/types/SSHKeys.ts +54 -0
  39. package/src/types/Tokens.ts +39 -0
@@ -25,7 +25,11 @@ import type {
25
25
  } from './types/FileNode';
26
26
  import { isExternalUsable } from './types/FileNode';
27
27
  import { useFileApi, type GlobalSearchHit } from './composables/useFileApi';
28
- import { useUploadChunked, type UploadJob } from './composables/useUploadChunked';
28
+ import {
29
+ useUploadChunked,
30
+ isStagedUnsupported,
31
+ type UploadJob,
32
+ } from './composables/useUploadChunked';
29
33
  import { useSelection } from './composables/useSelection';
30
34
  import { useKeyboardShortcuts } from './composables/useKeyboardShortcuts';
31
35
  import { useLocale } from './composables/useLocale';
@@ -1493,7 +1497,7 @@ const contextActions = computed<ContextAction[]>(() => {
1493
1497
  // for the virtual root sets currentPath to EMPTY string, not '/'.
1494
1498
  // So the guard never fired and every mutation action leaked into
1495
1499
  // the menu at the depo listing — including new-folder + paste,
1496
- // which Burak called out in the most direct possible terms. Use
1500
+ // which Ada called out in the most direct possible terms. Use
1497
1501
  // the same empty-after-trim test as `atVirtualRoot` above.
1498
1502
  const trimmedPath = (currentPath.value ?? '').replace(/^\/+|\/+$/g, '');
1499
1503
  const inStorageRoot = multiStorageRoot.value && trimmedPath === '';
@@ -1531,7 +1535,7 @@ const contextActions = computed<ContextAction[]>(() => {
1531
1535
 
1532
1536
  // selectionActionList — the SINGLE source of truth for the actions offered on a
1533
1537
  // selection. BOTH the right-click context menu AND the top toolbar render this
1534
- // exact list so they can never drift apart (Burak: "sağ klik menüyle üst menü
1538
+ // exact list so they can never drift apart (Ada: "sağ klik menüyle üst menü
1535
1539
  // tutmuyor"). The toolbar filters out dividers/hidden; the context menu shows
1536
1540
  // them. Action handling is unified in dispatchItemAction().
1537
1541
  function selectionActionList(sel: FileNode[]): ContextAction[] {
@@ -1940,13 +1944,25 @@ async function uploadFiles(list: File[]) {
1940
1944
  if (list.length === 0) return;
1941
1945
  }
1942
1946
  /* /wiring:e2 */
1943
- const canChunk = !!(api.endpoints.uploadInit && api.endpoints.uploadFinalize);
1944
1947
  for (const f of list) {
1945
- // Chunked (S3 multipart) only when the endpoints exist AND the file is
1946
- // large. If chunked isn't viable (storage has no multipart support
1947
- // e.g. the local driveror init errors out) fall back to the legacy
1948
- // single-POST upload, which works for any storage / file size.
1949
- if (canChunk && f.size >= 10 * 1024 * 1024) {
1948
+ // Anything above the chunk size goes on the STAGED path: chunked into
1949
+ // filex's own staging area, resumable across a dropped connection and via
1950
+ // the bookmark in lib/uploadResume across a reloaded tab. It works on
1951
+ // every driver, unlike the S3-presigned path it replaced. Small files keep
1952
+ // the single-POST fast path, and a server that has no staged path at all
1953
+ // falls back to it too.
1954
+ if (chunked.shouldChunk(f)) {
1955
+ const pending = chunked.resumableFor(qualify(currentPath.value), f);
1956
+ if (pending) {
1957
+ // Say so. An upload that silently starts over looks identical to one
1958
+ // that never happened, which is precisely the complaint.
1959
+ flashToast(
1960
+ t('upload.resuming', {
1961
+ name: f.name,
1962
+ percent: f.size > 0 ? Math.round((pending.offset / f.size) * 100) : 0,
1963
+ }),
1964
+ );
1965
+ }
1950
1966
  if (await chunkedUpload(f)) continue;
1951
1967
  }
1952
1968
  await legacyUpload(f);
@@ -1954,13 +1970,13 @@ async function uploadFiles(list: File[]) {
1954
1970
  await load();
1955
1971
  }
1956
1972
 
1957
- async function legacyUpload(file: File) {
1973
+ async function legacyUpload(file: File, dest?: string) {
1958
1974
  // Register a progress row so the corner badge tracks the upload — large files
1959
1975
  // fall back here from the chunked path, and previously showed no progress at
1960
1976
  // all (the chunked placeholder was removed on init failure and the legacy
1961
1977
  // POST tracked nothing, so the badge vanished mid-upload).
1962
1978
  const id = crypto.randomUUID();
1963
- const target = qualify(currentPath.value);
1979
+ const target = dest ?? qualify(currentPath.value);
1964
1980
  uploadJobs.value = [
1965
1981
  ...uploadJobs.value,
1966
1982
  { id, file, path: target, totalBytes: file.size, uploadedBytes: 0, percent: 0, status: 'uploading', cancel() {} },
@@ -1996,36 +2012,45 @@ async function legacyUpload(file: File) {
1996
2012
  }
1997
2013
 
1998
2014
  /**
1999
- * Attempt an S3 multipart (chunked) upload. Returns `true` on success,
2000
- * `false` when the storage can't do multipart (local driver, init 4xx/5xx)
2001
- * so the caller can transparently fall back to the legacy single-POST
2002
- * upload. On failure the progress placeholder is removed — no stuck error
2003
- * row, no error toast, because the fallback path will report any real error.
2015
+ * Attempt a staged (chunked, resumable) upload. Returns `true` when it was
2016
+ * handled — including when it failed and `false` ONLY when this server has no
2017
+ * staged path at all, so the caller may fall back to the single-POST upload.
2018
+ *
2019
+ * The old version fell back on ANY error, which was harmless while the
2020
+ * chunked path was S3-only and failed at init. It is not harmless now: a staged
2021
+ * upload that dies at 90 % has bytes on the server and a bookmark to resume
2022
+ * from, and quietly re-POSTing the whole file would throw both away — the
2023
+ * "starts from zero" behaviour this change exists to remove. A real failure is
2024
+ * shown to the user instead, and picking the same file again continues it.
2004
2025
  */
2005
- async function chunkedUpload(file: File): Promise<boolean> {
2006
- // Register the progress row LAZILY — only once init succeeded and bytes are
2007
- // actually moving. A doomed init (local driver / 4xx) then shows no badge at
2008
- // all, so the legacy fallback's own badge is the only one the user sees (no
2026
+ async function chunkedUpload(file: File, dest?: string): Promise<boolean> {
2027
+ // Register the progress row LAZILY — only once `begin` succeeded and bytes are
2028
+ // actually moving. A server with no staged path then shows no badge at all,
2029
+ // so the fallback's own badge is the only one the user sees (no
2009
2030
  // appear-then-vanish flicker).
2010
2031
  const id = crypto.randomUUID();
2011
2032
  let registered = false;
2033
+ const patch = (job: UploadJob) => {
2034
+ if (!registered) {
2035
+ uploadJobs.value = [...uploadJobs.value, { ...job, id } as UploadJob];
2036
+ registered = true;
2037
+ return;
2038
+ }
2039
+ const idx = uploadJobs.value.findIndex((j) => j.id === id);
2040
+ if (idx !== -1) {
2041
+ const next = [...uploadJobs.value];
2042
+ next[idx] = { ...job, id } as UploadJob;
2043
+ uploadJobs.value = next;
2044
+ }
2045
+ };
2046
+ const target = dest ?? qualify(currentPath.value);
2012
2047
  try {
2013
2048
  await chunked.uploadFile({
2014
- path: qualify(currentPath.value),
2049
+ path: target,
2015
2050
  file,
2016
2051
  onProgress: (job) => {
2017
- if (!registered) {
2018
- if (job.status !== 'uploading' && job.uploadedBytes <= 0) return;
2019
- uploadJobs.value = [...uploadJobs.value, { ...job, id } as UploadJob];
2020
- registered = true;
2021
- } else {
2022
- const idx = uploadJobs.value.findIndex((j) => j.id === id);
2023
- if (idx !== -1) {
2024
- const next = [...uploadJobs.value];
2025
- next[idx] = { ...job, id } as UploadJob;
2026
- uploadJobs.value = next;
2027
- }
2028
- }
2052
+ if (!registered && job.status !== 'uploading' && job.uploadedBytes <= 0) return;
2053
+ patch(job);
2029
2054
  emit('upload-progress', {
2030
2055
  uploadId: job.uploadId ?? id,
2031
2056
  percent: job.percent,
@@ -2034,9 +2059,32 @@ async function chunkedUpload(file: File): Promise<boolean> {
2034
2059
  },
2035
2060
  });
2036
2061
  return true;
2037
- } catch {
2038
- if (registered) uploadJobs.value = uploadJobs.value.filter((j) => j.id !== id);
2039
- return false;
2062
+ } catch (err) {
2063
+ if (isStagedUnsupported(err)) {
2064
+ if (registered) uploadJobs.value = uploadJobs.value.filter((j) => j.id !== id);
2065
+ return false;
2066
+ }
2067
+ const message = (err as Error).message;
2068
+ if (!registered) {
2069
+ uploadJobs.value = [
2070
+ ...uploadJobs.value,
2071
+ {
2072
+ id,
2073
+ file,
2074
+ path: target,
2075
+ totalBytes: file.size,
2076
+ uploadedBytes: 0,
2077
+ percent: 0,
2078
+ status: 'error',
2079
+ error: message,
2080
+ cancel() {},
2081
+ } as UploadJob,
2082
+ ];
2083
+ registered = true;
2084
+ }
2085
+ flashToast(t('upload.failed', { name: file.name }));
2086
+ emit('error', { message, context: { op: 'upload', file: file.name } });
2087
+ return true;
2040
2088
  }
2041
2089
  }
2042
2090
 
@@ -2471,42 +2519,29 @@ watch(
2471
2519
  const opsCenter = useOperations();
2472
2520
 
2473
2521
  /**
2474
- * Retry a failed upload from the operations center. The failed row is
2475
- * already retired by the store; re-run the upload against the job's ORIGINAL
2476
- * target folder (the user may have navigated away since) via the legacy
2477
- * single-POST path — works for any storage / size, no chunked precondition.
2522
+ * Retry a failed upload from the operations center. The failed row is already
2523
+ * retired by the store; re-run the upload against the job's ORIGINAL target
2524
+ * folder (the user may have navigated away since).
2525
+ *
2526
+ * ⚠ It goes back through the SAME decision a fresh upload makes, rather than
2527
+ * straight to the single-POST path as it used to. A retry is the moment resume
2528
+ * matters most: the staged session and its bookmark are still there, so this
2529
+ * continues from filex's offset instead of pushing the whole file again.
2478
2530
  */
2479
2531
  function retryUploadJob(job: UploadJob) {
2480
2532
  uploadJobs.value = uploadJobs.value.filter((j) => j.id !== job.id);
2481
2533
  const file = job.file;
2482
2534
  const target = job.path || qualify(currentPath.value);
2483
- const id = crypto.randomUUID();
2484
- uploadJobs.value = [
2485
- ...uploadJobs.value,
2486
- { id, file, path: target, totalBytes: file.size, uploadedBytes: 0, percent: 0, status: 'uploading', cancel() {} },
2487
- ];
2488
- const patchRetry = (p: Partial<UploadJob>) => {
2489
- const idx = uploadJobs.value.findIndex((j) => j.id === id);
2490
- if (idx === -1) return;
2491
- const next = [...uploadJobs.value];
2492
- next[idx] = { ...next[idx], ...p };
2493
- uploadJobs.value = next;
2494
- };
2495
- api
2496
- .uploadMultipart(target, [file], (percent) => {
2497
- patchRetry({ percent, uploadedBytes: Math.round((percent / 100) * file.size) });
2498
- emit('upload-progress', { uploadId: id, percent, done: percent >= 100 });
2499
- })
2500
- .then(() => {
2501
- patchRetry({ percent: 100, uploadedBytes: file.size, status: 'done' });
2502
- emit('upload-progress', { uploadId: id, percent: 100, done: true });
2503
- void load();
2504
- })
2505
- .catch((err: Error) => {
2506
- patchRetry({ status: 'error', error: err.message });
2507
- flashToast(t('upload.failed', { name: file.name }));
2508
- emit('error', { message: err.message, context: { op: 'upload-retry', file: file.name } });
2509
- });
2535
+ void (async () => {
2536
+ if (chunked.shouldChunk(file)) {
2537
+ if (await chunkedUpload(file, target)) {
2538
+ await load();
2539
+ return;
2540
+ }
2541
+ }
2542
+ await legacyUpload(file, target);
2543
+ await load();
2544
+ })();
2510
2545
  }
2511
2546
  /* /wiring:c3 */
2512
2547
  /* === wiring:c4 — onboarding coach-mark tour ===
@@ -2716,7 +2751,7 @@ function onPaneOpenTrash() {
2716
2751
  }
2717
2752
  /* ui-fix — pane'in KENDİ görünüm modu: split açılırken ana panelinkini
2718
2753
  * devralır, sonrasında bağımsız. Toolbar'ın görünüm değiştiricisi ve palet
2719
- * toggle'ı AKTİF panele yazar (Burak: "B tıklıyken ikon değiştir dersem
2754
+ * toggle'ı AKTİF panele yazar (Ada: "B tıklıyken ikon değiştir dersem
2720
2755
  * B'nin değişmesi lazım"). */
2721
2756
  const paneViewMode = computed<ViewMode>(() => activeSplit.value?.viewMode ?? viewMode.value);
2722
2757
  function setPaneViewMode(v: ViewMode) {
@@ -0,0 +1,333 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * ConnectionGuideView — renders one `ProtocolGuide`.
4
+ *
5
+ * The guide itself is generated from the live deployment
6
+ * (`lib/connectionGuides.ts`); this component only draws it. Keeping the
7
+ * two apart is what lets S3 and SFTP arrive as a builder each rather than
8
+ * as a second page with its own copy buttons and its own bugs.
9
+ */
10
+ import { computed, ref } from 'vue';
11
+ import type { LocaleCode } from '../types/ExplorerConfig';
12
+ import type { GuideBlock, ProtocolGuide } from '../lib/connectionGuides';
13
+ import { useLocale } from '../composables/useLocale';
14
+
15
+ const props = defineProps<{
16
+ guide: ProtocolGuide;
17
+ locale: LocaleCode;
18
+ }>();
19
+
20
+ const { t } = useLocale(() => props.locale);
21
+
22
+ const activeClient = ref<string>(props.guide.clients[0]?.id ?? '');
23
+ const copied = ref<string | null>(null);
24
+ let copyTimer: ReturnType<typeof setTimeout> | null = null;
25
+
26
+ const client = computed(
27
+ () => props.guide.clients.find((c) => c.id === activeClient.value) ?? props.guide.clients[0],
28
+ );
29
+
30
+ /**
31
+ * Copy, with a fallback.
32
+ *
33
+ * `navigator.clipboard` needs a secure context. The desktop app's `app://`
34
+ * scheme is registered secure, and the web app is on https — but an embed
35
+ * on plain http is a real deployment too, and a copy button that silently
36
+ * does nothing there is worse than no button.
37
+ */
38
+ async function copy(text: string, id: string) {
39
+ let ok = false;
40
+ try {
41
+ await navigator.clipboard.writeText(text);
42
+ ok = true;
43
+ } catch {
44
+ try {
45
+ const ta = document.createElement('textarea');
46
+ ta.value = text;
47
+ ta.setAttribute('readonly', '');
48
+ ta.style.position = 'fixed';
49
+ ta.style.opacity = '0';
50
+ document.body.appendChild(ta);
51
+ ta.select();
52
+ ok = document.execCommand('copy');
53
+ document.body.removeChild(ta);
54
+ } catch {
55
+ ok = false;
56
+ }
57
+ }
58
+ copied.value = ok ? id : null;
59
+ if (copyTimer) clearTimeout(copyTimer);
60
+ copyTimer = setTimeout(() => {
61
+ copied.value = null;
62
+ }, 1600);
63
+ }
64
+
65
+ function blockId(prefix: string, i: number): string {
66
+ return `${prefix}-${i}`;
67
+ }
68
+
69
+ function isCode(b: GuideBlock): boolean {
70
+ return b.kind === 'code';
71
+ }
72
+ </script>
73
+
74
+ <template>
75
+ <div class="fe-guide">
76
+ <p class="fe-guide__summary">{{ guide.summary }}</p>
77
+
78
+ <!-- The facts. This is the part that makes the page worth generating:
79
+ the real host, the real storage, the caller's own username. -->
80
+ <section class="fe-guide__facts" data-testid="guide-facts">
81
+ <div v-for="(f, i) in guide.facts" :key="f.label" class="fe-guide__fact">
82
+ <span class="fe-guide__factlabel">{{ f.label }}</span>
83
+ <span class="fe-guide__factvalue">
84
+ <code :class="{ 'fe-guide__ph': f.placeholderOnly }">{{ f.value }}</code>
85
+ <button
86
+ v-if="!f.placeholderOnly"
87
+ type="button"
88
+ class="fe-guide__copy"
89
+ :data-testid="`copy-fact-${i}`"
90
+ @click="copy(f.value, blockId('fact', i))"
91
+ >
92
+ {{ copied === blockId('fact', i) ? t('conn.guide.copied') : t('conn.guide.copy') }}
93
+ </button>
94
+ </span>
95
+ <span v-if="f.hint" class="fe-guide__facthint">{{ f.hint }}</span>
96
+ </div>
97
+ </section>
98
+
99
+ <!-- One tab per client. A "how to connect" page that covers one OS is a
100
+ page most readers bounce off. -->
101
+ <nav class="fe-guide__tabs" role="tablist">
102
+ <button
103
+ v-for="c in guide.clients"
104
+ :key="c.id"
105
+ type="button"
106
+ role="tab"
107
+ class="fe-guide__tab"
108
+ :class="{ 'is-active': client && c.id === client.id }"
109
+ :aria-selected="client && c.id === client.id"
110
+ :data-testid="`guide-tab-${c.id}`"
111
+ @click="activeClient = c.id"
112
+ >
113
+ {{ c.name }}
114
+ </button>
115
+ </nav>
116
+
117
+ <div v-if="client" class="fe-guide__body" role="tabpanel">
118
+ <template v-for="(b, i) in client.blocks" :key="i">
119
+ <ol v-if="b.kind === 'steps'" class="fe-guide__steps">
120
+ <li v-for="(s, j) in b.steps ?? []" :key="j">{{ s }}</li>
121
+ </ol>
122
+ <div v-else-if="isCode(b)" class="fe-guide__codewrap">
123
+ <div class="fe-guide__codehead">
124
+ <span class="fe-guide__caption">{{ b.caption }}</span>
125
+ <button
126
+ type="button"
127
+ class="fe-guide__copy"
128
+ :data-testid="`copy-code-${i}`"
129
+ @click="copy(b.code ?? '', blockId(client.id, i))"
130
+ >
131
+ {{ copied === blockId(client.id, i) ? t('conn.guide.copied') : t('conn.guide.copy') }}
132
+ </button>
133
+ </div>
134
+ <pre class="fe-guide__code"><code>{{ b.code }}</code></pre>
135
+ </div>
136
+ <p v-else-if="b.kind === 'warn'" class="fe-guide__warn">{{ b.text }}</p>
137
+ <p v-else-if="b.kind === 'note'" class="fe-guide__note">{{ b.text }}</p>
138
+ <p v-else class="fe-guide__text">{{ b.text }}</p>
139
+ </template>
140
+ </div>
141
+
142
+ <section v-if="guide.notes.length" class="fe-guide__notes">
143
+ <h4 class="fe-guide__notestitle">{{ t('conn.guide.goodToKnow') }}</h4>
144
+ <p
145
+ v-for="(n, i) in guide.notes"
146
+ :key="i"
147
+ :class="n.kind === 'warn' ? 'fe-guide__warn' : 'fe-guide__note'"
148
+ >
149
+ {{ n.text }}
150
+ </p>
151
+ </section>
152
+ </div>
153
+ </template>
154
+
155
+ <style>
156
+ .fe-guide {
157
+ display: flex;
158
+ flex-direction: column;
159
+ gap: 16px;
160
+ }
161
+ .fe-guide__summary {
162
+ margin: 0;
163
+ color: var(--fe-text-muted);
164
+ font-size: 13.5px;
165
+ line-height: 1.55;
166
+ }
167
+ .fe-guide__facts {
168
+ display: flex;
169
+ flex-direction: column;
170
+ gap: 10px;
171
+ border: 1px solid var(--fe-border);
172
+ border-radius: var(--fe-radius);
173
+ background: var(--fe-bg-elev);
174
+ padding: 12px 14px;
175
+ }
176
+ .fe-guide__fact {
177
+ display: grid;
178
+ grid-template-columns: minmax(120px, 180px) 1fr;
179
+ gap: 4px 14px;
180
+ align-items: baseline;
181
+ }
182
+ .fe-guide__factlabel {
183
+ font-size: 12.5px;
184
+ font-weight: 600;
185
+ color: var(--fe-text-muted);
186
+ }
187
+ .fe-guide__factvalue {
188
+ display: flex;
189
+ align-items: center;
190
+ gap: 8px;
191
+ min-width: 0;
192
+ flex-wrap: wrap;
193
+ }
194
+ .fe-guide__factvalue code {
195
+ font-family: var(--fe-font-mono);
196
+ font-size: 12.5px;
197
+ color: var(--fe-text);
198
+ background: var(--fe-bg);
199
+ border: 1px solid var(--fe-border);
200
+ border-radius: var(--fe-radius-sm);
201
+ padding: 2px 6px;
202
+ overflow-wrap: anywhere;
203
+ }
204
+ .fe-guide__ph {
205
+ color: var(--fe-text-muted) !important;
206
+ font-style: italic;
207
+ }
208
+ .fe-guide__facthint {
209
+ grid-column: 2;
210
+ font-size: 12px;
211
+ color: var(--fe-text-muted);
212
+ }
213
+ .fe-guide__tabs {
214
+ display: flex;
215
+ gap: 4px;
216
+ flex-wrap: wrap;
217
+ border-bottom: 1px solid var(--fe-border);
218
+ padding-bottom: 2px;
219
+ }
220
+ .fe-guide__tab {
221
+ font: inherit;
222
+ font-size: 13px;
223
+ border: 0;
224
+ background: none;
225
+ color: var(--fe-text-muted);
226
+ padding: 6px 10px;
227
+ border-radius: var(--fe-radius-sm) var(--fe-radius-sm) 0 0;
228
+ cursor: pointer;
229
+ border-bottom: 2px solid transparent;
230
+ }
231
+ .fe-guide__tab:hover {
232
+ color: var(--fe-text);
233
+ background: var(--fe-bg-hover);
234
+ }
235
+ .fe-guide__tab.is-active {
236
+ color: var(--fe-primary);
237
+ border-bottom-color: var(--fe-primary);
238
+ font-weight: 600;
239
+ }
240
+ .fe-guide__body {
241
+ display: flex;
242
+ flex-direction: column;
243
+ gap: 12px;
244
+ }
245
+ .fe-guide__steps {
246
+ margin: 0;
247
+ padding-left: 20px;
248
+ display: flex;
249
+ flex-direction: column;
250
+ gap: 6px;
251
+ font-size: 13.5px;
252
+ line-height: 1.55;
253
+ color: var(--fe-text);
254
+ }
255
+ .fe-guide__codewrap {
256
+ border: 1px solid var(--fe-border);
257
+ border-radius: var(--fe-radius);
258
+ overflow: hidden;
259
+ background: var(--fe-bg-elev);
260
+ }
261
+ .fe-guide__codehead {
262
+ display: flex;
263
+ align-items: center;
264
+ justify-content: space-between;
265
+ gap: 10px;
266
+ padding: 6px 10px;
267
+ border-bottom: 1px solid var(--fe-border);
268
+ }
269
+ .fe-guide__caption {
270
+ font-size: 12px;
271
+ color: var(--fe-text-muted);
272
+ font-family: var(--fe-font-mono);
273
+ overflow-wrap: anywhere;
274
+ }
275
+ .fe-guide__code {
276
+ margin: 0;
277
+ padding: 10px 12px;
278
+ /* Wide command lines scroll INSIDE the block. Without this the whole
279
+ settings surface grows a horizontal scrollbar and the layout around
280
+ it breaks — the same failure the tab strip had. */
281
+ overflow-x: auto;
282
+ font-family: var(--fe-font-mono);
283
+ font-size: 12.5px;
284
+ line-height: 1.6;
285
+ color: var(--fe-text);
286
+ white-space: pre;
287
+ }
288
+ .fe-guide__copy {
289
+ font: inherit;
290
+ font-size: 12px;
291
+ border: 1px solid var(--fe-border-strong);
292
+ background: var(--fe-bg);
293
+ color: var(--fe-text);
294
+ border-radius: var(--fe-radius-sm);
295
+ padding: 3px 9px;
296
+ cursor: pointer;
297
+ flex: 0 0 auto;
298
+ }
299
+ .fe-guide__copy:hover {
300
+ border-color: var(--fe-primary);
301
+ color: var(--fe-primary);
302
+ }
303
+ .fe-guide__note,
304
+ .fe-guide__warn,
305
+ .fe-guide__text {
306
+ margin: 0;
307
+ font-size: 12.5px;
308
+ line-height: 1.55;
309
+ }
310
+ .fe-guide__text {
311
+ color: var(--fe-text);
312
+ }
313
+ .fe-guide__note {
314
+ color: var(--fe-text-muted);
315
+ }
316
+ .fe-guide__warn {
317
+ color: var(--fe-danger);
318
+ }
319
+ .fe-guide__notes {
320
+ display: flex;
321
+ flex-direction: column;
322
+ gap: 6px;
323
+ border-top: 1px solid var(--fe-border);
324
+ padding-top: 12px;
325
+ }
326
+ .fe-guide__notestitle {
327
+ margin: 0;
328
+ font-size: 12px;
329
+ text-transform: uppercase;
330
+ letter-spacing: 0.05em;
331
+ color: var(--fe-text-muted);
332
+ }
333
+ </style>