Compare commits

...

3 Commits

Author SHA1 Message Date
renovate[bot]
1c0f873c9d chore: bump up RevenueCat/purchases-ios-spm version to from: "5.58.0" (#14399)
This PR contains the following updates:

| Package | Update | Change |
|---|---|---|
|
[RevenueCat/purchases-ios-spm](https://redirect.github.com/RevenueCat/purchases-ios-spm)
| minor | `from: "5.56.1"` → `from: "5.58.0"` |

---

> [!WARNING]
> Some dependencies could not be looked up. Check the Dependency
Dashboard for more information.

---

### Release Notes

<details>
<summary>RevenueCat/purchases-ios-spm
(RevenueCat/purchases-ios-spm)</summary>

###
[`v5.58.0`](https://redirect.github.com/RevenueCat/purchases-ios-spm/compare/5.57.2...5.58.0)

[Compare
Source](https://redirect.github.com/RevenueCat/purchases-ios-spm/compare/5.57.2...5.58.0)

###
[`v5.57.2`](https://redirect.github.com/RevenueCat/purchases-ios-spm/blob/HEAD/CHANGELOG.md#5572)

[Compare
Source](https://redirect.github.com/RevenueCat/purchases-ios-spm/compare/5.57.1...5.57.2)

##### 🔄 Other Changes

- Make networkName nullable in ad event data types
([#&#8203;6229](https://redirect.github.com/RevenueCat/purchases-ios-spm/issues/6229))
via Pol Miro ([@&#8203;polmiro](https://redirect.github.com/polmiro))
- Remove networkName from AdFailedToLoad event
([#&#8203;6208](https://redirect.github.com/RevenueCat/purchases-ios-spm/issues/6208))
via Pol Miro ([@&#8203;polmiro](https://redirect.github.com/polmiro))
- Excluding xcarchive and separate dSYMs folder from XCFramework in
order to reduce download size
([#&#8203;5967](https://redirect.github.com/RevenueCat/purchases-ios-spm/issues/5967))
via Rick ([@&#8203;rickvdl](https://redirect.github.com/rickvdl))

###
[`v5.57.1`](https://redirect.github.com/RevenueCat/purchases-ios-spm/blob/HEAD/CHANGELOG.md#5571)

[Compare
Source](https://redirect.github.com/RevenueCat/purchases-ios-spm/compare/5.57.0...5.57.1)

#### 5.57.1

###
[`v5.57.0`](https://redirect.github.com/RevenueCat/purchases-ios-spm/blob/HEAD/CHANGELOG.md#5570)

[Compare
Source](https://redirect.github.com/RevenueCat/purchases-ios-spm/compare/5.56.1...5.57.0)

#### 5.57.0

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined),
Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you
are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the
rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update
again.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/toeverything/AFFiNE).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0Mi45NS4yIiwidXBkYXRlZEluVmVyIjoiNDIuOTUuMiIsInRhcmdldEJyYW5jaCI6ImNhbmFyeSIsImxhYmVscyI6WyJkZXBlbmRlbmNpZXMiXX0=-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-09 00:41:34 +08:00
DarkSky
8b68574820 fix: old workspace migration (#14403)
fix #14395 

#### PR Dependency Tree


* **PR #14403** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
  * Added ability to enumerate and list local workspaces.
* Improved workspace ID persistence with Electron global-state storage,
automatic fallback to legacy storage, and one-time migration to
consolidate IDs.
* **Tests**
* Added unit test validating listing behavior (includes/excludes
workspaces based on presence of workspace DB file).
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-02-09 00:22:37 +08:00
DarkSky
bb01bb1aef fix(editor): database behavier (#14394)
fix #13459
fix #13707
fix #13924

#### PR Dependency Tree


* **PR #14394** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Improved URL paste: text is split into segments, inserted correctly,
and single-URL pastes create linked-page references.

* **UI Improvements**
  * Redesigned layout selector with compact dynamic options.
* Number-format options are always available in table headers and mobile
menus.

* **Bug Fixes**
  * More consistent paste behavior for mixed text+URL content.
  * Prevented recursive selection updates when exiting edit mode.

* **Tests**
* Added tests for URL splitting, paste insertion, number formatting, and
selection behavior.

* **Chores**
* Removed number-formatting feature flag; formatting now applied by
default.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-02-08 23:31:30 +08:00
26 changed files with 855 additions and 317 deletions

View File

@@ -0,0 +1,95 @@
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
import { describe, expect, test } from 'vitest';
import { insertUrlTextSegments } from '../../../../blocks/database/src/properties/paste-url.js';
type InsertCall = {
range: {
index: number;
length: number;
};
text: string;
attributes?: AffineTextAttributes;
};
describe('insertUrlTextSegments', () => {
test('should replace selected text on first insert and append remaining segments', () => {
const insertCalls: InsertCall[] = [];
const selectionCalls: Array<{ index: number; length: number } | null> = [];
const inlineEditor = {
insertText: (
range: { index: number; length: number },
text: string,
attributes?: AffineTextAttributes
) => {
insertCalls.push({ range, text, attributes });
},
setInlineRange: (range: { index: number; length: number } | null) => {
selectionCalls.push(range);
},
};
const inlineRange = { index: 4, length: 6 };
const segments = [
{ text: 'hi - ' },
{ text: 'https://google.com', link: 'https://google.com' },
];
insertUrlTextSegments(inlineEditor, inlineRange, segments);
expect(insertCalls).toEqual([
{
range: { index: 4, length: 6 },
text: 'hi - ',
},
{
range: { index: 9, length: 0 },
text: 'https://google.com',
attributes: {
link: 'https://google.com',
},
},
]);
expect(selectionCalls).toEqual([{ index: 27, length: 0 }]);
});
test('should keep insertion range length zero when there is no selected text', () => {
const insertCalls: InsertCall[] = [];
const selectionCalls: Array<{ index: number; length: number } | null> = [];
const inlineEditor = {
insertText: (
range: { index: number; length: number },
text: string,
attributes?: AffineTextAttributes
) => {
insertCalls.push({ range, text, attributes });
},
setInlineRange: (range: { index: number; length: number } | null) => {
selectionCalls.push(range);
},
};
const inlineRange = { index: 2, length: 0 };
const segments = [
{ text: 'prefix ' },
{ text: 'https://a.com', link: 'https://a.com' },
];
insertUrlTextSegments(inlineEditor, inlineRange, segments);
expect(insertCalls).toEqual([
{
range: { index: 2, length: 0 },
text: 'prefix ',
},
{
range: { index: 9, length: 0 },
text: 'https://a.com',
attributes: {
link: 'https://a.com',
},
},
]);
expect(selectionCalls).toEqual([{ index: 22, length: 0 }]);
});
});

View File

@@ -135,14 +135,10 @@ export class DatabaseBlockDataSource extends DataSourceBase {
override featureFlags$: ReadonlySignal<DatabaseFlags> = computed(() => {
const featureFlagService = this.doc.get(FeatureFlagService);
const enableNumberFormat = featureFlagService.getFlag(
'enable_database_number_formatting'
);
const enableTableVirtualScroll = featureFlagService.getFlag(
'enable_table_virtual_scroll'
);
return {
enable_number_formatting: enableNumberFormat ?? false,
enable_table_virtual_scroll: enableTableVirtualScroll ?? false,
};
});

View File

@@ -0,0 +1,56 @@
import type {
AffineInlineEditor,
AffineTextAttributes,
} from '@blocksuite/affine-shared/types';
import {
splitTextByUrl,
type UrlTextSegment,
} from '@blocksuite/affine-shared/utils';
import type { InlineRange } from '@blocksuite/std/inline';
type UrlPasteInlineEditor = Pick<
AffineInlineEditor,
'insertText' | 'setInlineRange'
>;
export function analyzeTextForUrlPaste(text: string) {
const segments = splitTextByUrl(text);
const firstSegment = segments[0];
const singleUrl =
segments.length === 1 && firstSegment?.link && firstSegment.text === text
? firstSegment.link
: undefined;
return {
segments,
singleUrl,
};
}
export function insertUrlTextSegments(
inlineEditor: UrlPasteInlineEditor,
inlineRange: InlineRange,
segments: UrlTextSegment[]
) {
let index = inlineRange.index;
let replacedSelection = false;
segments.forEach(segment => {
if (!segment.text) return;
const attributes: AffineTextAttributes | undefined = segment.link
? { link: segment.link }
: undefined;
inlineEditor.insertText(
{
index,
length: replacedSelection ? 0 : inlineRange.length,
},
segment.text,
attributes
);
replacedSelection = true;
index += segment.text.length;
});
inlineEditor.setInlineRange({
index,
length: 0,
});
}

View File

@@ -8,10 +8,7 @@ import type {
AffineInlineEditor,
AffineTextAttributes,
} from '@blocksuite/affine-shared/types';
import {
getViewportElement,
isValidUrl,
} from '@blocksuite/affine-shared/utils';
import { getViewportElement } from '@blocksuite/affine-shared/utils';
import {
BaseCellRenderer,
createFromBaseCellRenderer,
@@ -26,6 +23,7 @@ import { html } from 'lit/static-html.js';
import { EditorHostKey } from '../../context/host-context.js';
import type { DatabaseBlockComponent } from '../../database-block.js';
import { analyzeTextForUrlPaste, insertUrlTextSegments } from '../paste-url.js';
import {
richTextCellStyle,
richTextContainerStyle,
@@ -271,10 +269,13 @@ export class RichTextCell extends BaseCellRenderer<Text, string> {
?.getData('text/plain')
?.replace(/\r?\n|\r/g, '\n');
if (!text) return;
const { segments, singleUrl } = analyzeTextForUrlPaste(text);
if (isValidUrl(text)) {
if (singleUrl) {
const std = this.std;
const result = std?.getOptional(ParseDocUrlProvider)?.parseDocUrl(text);
const result = std
?.getOptional(ParseDocUrlProvider)
?.parseDocUrl(singleUrl);
if (result) {
const text = ' ';
inlineEditor.insertText(inlineRange, text, {
@@ -300,22 +301,10 @@ export class RichTextCell extends BaseCellRenderer<Text, string> {
segment: 'database',
parentFlavour: 'affine:database',
});
} else {
inlineEditor.insertText(inlineRange, text, {
link: text,
});
inlineEditor.setInlineRange({
index: inlineRange.index + text.length,
length: 0,
});
return;
}
} else {
inlineEditor.insertText(inlineRange, text);
inlineEditor.setInlineRange({
index: inlineRange.index + text.length,
length: 0,
});
}
insertUrlTextSegments(inlineEditor, inlineRange, segments);
};
override connectedCallback() {

View File

@@ -4,10 +4,7 @@ import {
ParseDocUrlProvider,
TelemetryProvider,
} from '@blocksuite/affine-shared/services';
import {
getViewportElement,
isValidUrl,
} from '@blocksuite/affine-shared/utils';
import { getViewportElement } from '@blocksuite/affine-shared/utils';
import { BaseCellRenderer } from '@blocksuite/data-view';
import { IS_MAC } from '@blocksuite/global/env';
import { LinkedPageIcon } from '@blocksuite/icons/lit';
@@ -20,6 +17,7 @@ import { html } from 'lit/static-html.js';
import { EditorHostKey } from '../../context/host-context.js';
import type { DatabaseBlockComponent } from '../../database-block.js';
import { getSingleDocIdFromText } from '../../utils/title-doc.js';
import { analyzeTextForUrlPaste, insertUrlTextSegments } from '../paste-url.js';
import {
headerAreaIconStyle,
titleCellStyle,
@@ -95,7 +93,9 @@ export class HeaderAreaTextCell extends BaseCellRenderer<Text, string> {
private readonly _onPaste = (e: ClipboardEvent) => {
const inlineEditor = this.inlineEditor;
const inlineRange = inlineEditor?.getInlineRange();
if (!inlineRange) return;
if (!inlineEditor || !inlineRange) return;
e.preventDefault();
e.stopPropagation();
if (e.clipboardData) {
try {
const getDeltas = (snapshot: BlockSnapshot): DeltaInsert[] => {
@@ -121,14 +121,15 @@ export class HeaderAreaTextCell extends BaseCellRenderer<Text, string> {
?.getData('text/plain')
?.replace(/\r?\n|\r/g, '\n');
if (!text) return;
e.preventDefault();
e.stopPropagation();
if (isValidUrl(text)) {
const { segments, singleUrl } = analyzeTextForUrlPaste(text);
if (singleUrl) {
const std = this.std;
const result = std?.getOptional(ParseDocUrlProvider)?.parseDocUrl(text);
const result = std
?.getOptional(ParseDocUrlProvider)
?.parseDocUrl(singleUrl);
if (result) {
const text = ' ';
inlineEditor?.insertText(inlineRange, text, {
inlineEditor.insertText(inlineRange, text, {
reference: {
type: 'LinkedPage',
pageId: result.docId,
@@ -139,7 +140,7 @@ export class HeaderAreaTextCell extends BaseCellRenderer<Text, string> {
},
},
});
inlineEditor?.setInlineRange({
inlineEditor.setInlineRange({
index: inlineRange.index + text.length,
length: 0,
});
@@ -151,22 +152,10 @@ export class HeaderAreaTextCell extends BaseCellRenderer<Text, string> {
segment: 'database',
parentFlavour: 'affine:database',
});
} else {
inlineEditor?.insertText(inlineRange, text, {
link: text,
});
inlineEditor?.setInlineRange({
index: inlineRange.index + text.length,
length: 0,
});
return;
}
} else {
inlineEditor?.insertText(inlineRange, text);
inlineEditor?.setInlineRange({
index: inlineRange.index + text.length,
length: 0,
});
}
insertUrlTextSegments(inlineEditor, inlineRange, segments);
};
insertDelta = (delta: DeltaInsert) => {
@@ -240,7 +229,8 @@ export class HeaderAreaTextCell extends BaseCellRenderer<Text, string> {
this.disposables.addFromEvent(
this.richText.value,
'paste',
this._onPaste
this._onPaste,
true
);
const inlineEditor = this.inlineEditor;
if (inlineEditor) {

View File

@@ -3,8 +3,10 @@ import { describe, expect, it, vi } from 'vitest';
import type { GroupBy } from '../core/common/types.js';
import type { DataSource } from '../core/data-source/base.js';
import { DetailSelection } from '../core/detail/selection.js';
import { groupByMatchers } from '../core/group-by/define.js';
import { t } from '../core/logical/type-presets.js';
import type { DataViewCellLifeCycle } from '../core/property/index.js';
import { checkboxPropertyModelConfig } from '../property-presets/checkbox/define.js';
import { multiSelectPropertyModelConfig } from '../property-presets/multi-select/define.js';
import { selectPropertyModelConfig } from '../property-presets/select/define.js';
@@ -456,4 +458,60 @@ describe('kanban', () => {
expect(next?.hideEmpty).toBe(true);
});
});
describe('detail selection', () => {
it('should avoid recursive selection update when exiting select edit mode', () => {
vi.stubGlobal('requestAnimationFrame', ((cb: FrameRequestCallback) => {
cb(0);
return 0;
}) as typeof requestAnimationFrame);
try {
let selection: DetailSelection;
let beforeExitCalls = 0;
const cell = {
beforeEnterEditMode: () => true,
beforeExitEditingMode: () => {
beforeExitCalls += 1;
selection.selection = {
propertyId: 'status',
isEditing: false,
};
},
afterEnterEditingMode: () => {},
focusCell: () => true,
blurCell: () => true,
forceUpdate: () => {},
} satisfies DataViewCellLifeCycle;
const field = {
isFocus$: signal(false),
isEditing$: signal(false),
cell,
focus: () => {},
blur: () => {},
};
const detail = {
querySelector: () => field,
};
selection = new DetailSelection(detail);
selection.selection = {
propertyId: 'status',
isEditing: true,
};
selection.selection = {
propertyId: 'status',
isEditing: false,
};
expect(beforeExitCalls).toBe(1);
expect(field.isEditing$.value).toBe(false);
} finally {
vi.unstubAllGlobals();
}
});
});
});

View File

@@ -1,36 +0,0 @@
import { describe, expect, test } from 'vitest';
import { mobileEffects } from '../view-presets/table/mobile/effect.js';
import type { MobileTableGroup } from '../view-presets/table/mobile/group.js';
import { pcEffects } from '../view-presets/table/pc/effect.js';
import type { TableGroup } from '../view-presets/table/pc/group.js';
/** @vitest-environment happy-dom */
describe('TableGroup', () => {
test('toggle collapse on pc', () => {
pcEffects();
const group = document.createElement(
'affine-data-view-table-group'
) as TableGroup;
expect(group.collapsed$.value).toBe(false);
(group as any)._toggleCollapse();
expect(group.collapsed$.value).toBe(true);
(group as any)._toggleCollapse();
expect(group.collapsed$.value).toBe(false);
});
test('toggle collapse on mobile', () => {
mobileEffects();
const group = document.createElement(
'mobile-table-group'
) as MobileTableGroup;
expect(group.collapsed$.value).toBe(false);
(group as any)._toggleCollapse();
expect(group.collapsed$.value).toBe(true);
(group as any)._toggleCollapse();
expect(group.collapsed$.value).toBe(false);
});
});

View File

@@ -0,0 +1,101 @@
import { describe, expect, test } from 'vitest';
import { numberFormats } from '../property-presets/number/utils/formats.js';
import {
formatNumber,
NumberFormatSchema,
parseNumber,
} from '../property-presets/number/utils/formatter.js';
import { mobileEffects } from '../view-presets/table/mobile/effect.js';
import type { MobileTableGroup } from '../view-presets/table/mobile/group.js';
import { pcEffects } from '../view-presets/table/pc/effect.js';
import type { TableGroup } from '../view-presets/table/pc/group.js';
/** @vitest-environment happy-dom */
describe('TableGroup', () => {
test('toggle collapse on pc', () => {
pcEffects();
const group = document.createElement(
'affine-data-view-table-group'
) as TableGroup;
expect(group.collapsed$.value).toBe(false);
(group as any)._toggleCollapse();
expect(group.collapsed$.value).toBe(true);
(group as any)._toggleCollapse();
expect(group.collapsed$.value).toBe(false);
});
test('toggle collapse on mobile', () => {
mobileEffects();
const group = document.createElement(
'mobile-table-group'
) as MobileTableGroup;
expect(group.collapsed$.value).toBe(false);
(group as any)._toggleCollapse();
expect(group.collapsed$.value).toBe(true);
(group as any)._toggleCollapse();
expect(group.collapsed$.value).toBe(false);
});
});
describe('number formatter', () => {
test('number format menu should expose all schema formats', () => {
const menuFormats = numberFormats.map(format => format.type);
const schemaFormats = NumberFormatSchema.options;
expect(new Set(menuFormats)).toEqual(new Set(schemaFormats));
expect(menuFormats).toHaveLength(schemaFormats.length);
});
test('formats grouped decimal numbers with Intl grouping rules', () => {
const value = 11451.4;
const decimals = 1;
const expected = new Intl.NumberFormat(navigator.language, {
style: 'decimal',
useGrouping: true,
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
}).format(value);
expect(formatNumber(value, 'numberWithCommas', decimals)).toBe(expected);
});
test('formats percent values with Intl percent rules', () => {
const value = 0.1234;
const decimals = 2;
const expected = new Intl.NumberFormat(navigator.language, {
style: 'percent',
useGrouping: false,
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
}).format(value);
expect(formatNumber(value, 'percent', decimals)).toBe(expected);
});
test('formats currency values with Intl currency rules', () => {
const value = 11451.4;
const expected = new Intl.NumberFormat(navigator.language, {
style: 'currency',
currency: 'USD',
currencyDisplay: 'symbol',
}).format(value);
expect(formatNumber(value, 'currencyUSD')).toBe(expected);
});
test('parses grouped number string pasted from clipboard', () => {
expect(parseNumber('11,451.4')).toBe(11451.4);
});
test('keeps regular decimal parsing', () => {
expect(parseNumber('123.45')).toBe(123.45);
});
test('supports comma as decimal separator in locale-specific input', () => {
expect(parseNumber('11451,4', ',')).toBe(11451.4);
});
});

View File

@@ -22,7 +22,6 @@ import { html } from 'lit/static-html.js';
import { dataViewCommonStyle } from './common/css-variable.js';
import type { DataSource } from './data-source/index.js';
import type { DataViewSelection } from './types.js';
import { cacheComputed } from './utils/cache.js';
import { renderUniLit } from './utils/uni-component/index.js';
import type { DataViewUILogicBase } from './view/data-view-base.js';
import type { SingleView } from './view-manager/single-view.js';
@@ -75,12 +74,38 @@ export class DataViewRootUILogic {
return new (logic(view))(this, view);
}
private readonly views$ = cacheComputed(this.viewManager.views$, viewId =>
this.createDataViewUILogic(viewId)
);
private readonly _viewsCache = new Map<
string,
{ mode: string; logic: DataViewUILogicBase }
>();
private readonly views$ = computed(() => {
const viewDataList = this.dataSource.viewDataList$.value;
const validIds = new Set(viewDataList.map(viewData => viewData.id));
for (const cachedId of this._viewsCache.keys()) {
if (!validIds.has(cachedId)) {
this._viewsCache.delete(cachedId);
}
}
return viewDataList.map(viewData => {
const cached = this._viewsCache.get(viewData.id);
if (cached && cached.mode === viewData.mode) {
return cached.logic;
}
const logic = this.createDataViewUILogic(viewData.id);
this._viewsCache.set(viewData.id, {
mode: viewData.mode,
logic,
});
return logic;
});
});
private readonly viewsMap$ = computed(() => {
return Object.fromEntries(
this.views$.list.value.map(logic => [logic.view.id, logic])
this.views$.value.map(logic => [logic.view.id, logic])
);
});
private readonly _uiRef = signal<DataViewRootUI>();

View File

@@ -1,7 +1,6 @@
import type { KanbanCardSelection } from '../../view-presets';
import type { KanbanCard } from '../../view-presets/kanban/pc/card.js';
import { KanbanCell } from '../../view-presets/kanban/pc/cell.js';
import type { RecordDetail } from './detail.js';
import { RecordField } from './field.js';
type DetailViewSelection = {
@@ -9,16 +8,39 @@ type DetailViewSelection = {
isEditing: boolean;
};
type DetailSelectionHost = {
querySelector: (selector: string) => unknown;
};
const isSameDetailSelection = (
current?: DetailViewSelection,
next?: DetailViewSelection
) => {
if (!current && !next) {
return true;
}
if (!current || !next) {
return false;
}
return (
current.propertyId === next.propertyId &&
current.isEditing === next.isEditing
);
};
export class DetailSelection {
_selection?: DetailViewSelection;
onSelect = (selection?: DetailViewSelection) => {
if (isSameDetailSelection(this._selection, selection)) {
return;
}
const old = this._selection;
this._selection = selection;
if (old) {
this.blur(old);
}
this._selection = selection;
if (selection) {
if (selection && isSameDetailSelection(this._selection, selection)) {
this.focus(selection);
}
};
@@ -49,7 +71,7 @@ export class DetailSelection {
}
}
constructor(private readonly viewEle: RecordDetail) {}
constructor(private readonly viewEle: DetailSelectionHost) {}
blur(selection: DetailViewSelection) {
const container = this.getFocusCellContainer(selection);
@@ -111,8 +133,10 @@ export class DetailSelection {
}
focusFirstCell() {
const firstId = this.viewEle.querySelector('affine-data-view-record-field')
?.column.id;
const firstField = this.viewEle.querySelector(
'affine-data-view-record-field'
) as RecordField | undefined;
const firstId = firstField?.column.id;
if (firstId) {
this.selection = {
propertyId: firstId,
@@ -144,11 +168,12 @@ export class DetailSelection {
getSelectCard(selection: KanbanCardSelection) {
const { groupKey, cardId } = selection.cards[0];
const group = this.viewEle.querySelector(
`affine-data-view-kanban-group[data-key="${groupKey}"]`
) as HTMLElement | undefined;
return this.viewEle
.querySelector(`affine-data-view-kanban-group[data-key="${groupKey}"]`)
?.querySelector(
`affine-data-view-kanban-card[data-card-id="${cardId}"]`
) as KanbanCard | undefined;
return group?.querySelector(
`affine-data-view-kanban-card[data-card-id="${cardId}"]`
) as KanbanCard | undefined;
}
}

View File

@@ -12,6 +12,5 @@ export type PropertyDataUpdater<
> = (data: Data) => Partial<Data>;
export interface DatabaseFlags {
enable_number_formatting: boolean;
enable_table_virtual_scroll: boolean;
}

View File

@@ -24,17 +24,11 @@ export class NumberCell extends BaseCellRenderer<
private accessor _inputEle!: HTMLInputElement;
private _getFormattedString(value: number | undefined = this.value) {
const enableNewFormatting =
this.view.featureFlags$.value.enable_number_formatting;
const decimals = this.property.data$.value.decimal ?? 0;
const formatMode = (this.property.data$.value.format ??
'number') as NumberFormat;
return value != undefined
? enableNewFormatting
? formatNumber(value, formatMode, decimals)
: value.toString()
: '';
return value != undefined ? formatNumber(value, formatMode, decimals) : '';
}
private readonly _keydown = (e: KeyboardEvent) => {
@@ -58,9 +52,7 @@ export class NumberCell extends BaseCellRenderer<
return;
}
const enableNewFormatting =
this.view.featureFlags$.value.enable_number_formatting;
const value = enableNewFormatting ? parseNumber(str) : parseFloat(str);
const value = parseNumber(str);
if (isNaN(value)) {
if (this._inputEle) {
this._inputEle.value = this.value

View File

@@ -3,6 +3,7 @@ import zod from 'zod';
import { t } from '../../core/logical/type-presets.js';
import { propertyType } from '../../core/property/property-config.js';
import { NumberPropertySchema } from './types.js';
import { parseNumber } from './utils/formatter.js';
export const numberPropertyType = propertyType('number');
export const numberPropertyModelConfig = numberPropertyType.modelConfig({
@@ -21,7 +22,7 @@ export const numberPropertyModelConfig = numberPropertyType.modelConfig({
default: () => null,
toString: ({ value }) => value?.toString() ?? '',
fromString: ({ value }) => {
const num = value ? Number(value) : NaN;
const num = value ? parseNumber(value) : NaN;
return { value: isNaN(num) ? null : num };
},
toJson: ({ value }) => value ?? null,

View File

@@ -64,9 +64,6 @@ export class MobileTableColumnHeader extends SignalWatcher(
};
private popMenu(ele?: HTMLElement) {
const enableNumberFormatting =
this.tableViewManager.featureFlags$.value.enable_number_formatting;
popMenu(popupTargetFromElement(ele ?? this), {
options: {
title: {
@@ -76,41 +73,36 @@ export class MobileTableColumnHeader extends SignalWatcher(
inputConfig(this.column),
typeConfig(this.column),
// Number format begin
...(enableNumberFormatting
? [
menu.subMenu({
name: 'Number Format',
hide: () =>
!this.column.dataUpdate ||
this.column.type$.value !== 'number',
options: {
title: {
text: 'Number Format',
menu.subMenu({
name: 'Number Format',
hide: () =>
!this.column.dataUpdate || this.column.type$.value !== 'number',
options: {
title: {
text: 'Number Format',
},
items: [
numberFormatConfig(this.column),
...numberFormats.map(format => {
const data = this.column.data$.value;
return menu.action({
isSelected: data.format === format.type,
prefix: html`<span
style="font-size: var(--affine-font-base); scale: 1.2;"
>${format.symbol}</span
>`,
name: format.label,
select: () => {
if (data.format === format.type) return;
this.column.dataUpdate(() => ({
format: format.type,
}));
},
items: [
numberFormatConfig(this.column),
...numberFormats.map(format => {
const data = this.column.data$.value;
return menu.action({
isSelected: data.format === format.type,
prefix: html`<span
style="font-size: var(--affine-font-base); scale: 1.2;"
>${format.symbol}</span
>`,
name: format.label,
select: () => {
if (data.format === format.type) return;
this.column.dataUpdate(() => ({
format: format.type,
}));
},
});
}),
],
},
});
}),
]
: []),
],
},
}),
// Number format end
menu.group({
items: [

View File

@@ -205,47 +205,39 @@ export class DatabaseHeaderColumn extends SignalWatcher(
}
private popMenu(ele?: HTMLElement) {
const enableNumberFormatting =
this.tableViewManager.featureFlags$.value.enable_number_formatting;
popMenu(popupTargetFromElement(ele ?? this), {
options: {
items: [
inputConfig(this.column),
typeConfig(this.column),
// Number format begin
...(enableNumberFormatting
? [
menu.subMenu({
name: 'Number Format',
hide: () =>
!this.column.dataUpdate ||
this.column.type$.value !== 'number',
options: {
items: [
numberFormatConfig(this.column),
...numberFormats.map(format => {
const data = this.column.data$.value;
return menu.action({
isSelected: data.format === format.type,
prefix: html`<span
style="font-size: var(--affine-font-base); scale: 1.2;"
>${format.symbol}</span
>`,
name: format.label,
select: () => {
if (data.format === format.type) return;
this.column.dataUpdate(() => ({
format: format.type,
}));
},
});
}),
],
},
menu.subMenu({
name: 'Number Format',
hide: () =>
!this.column.dataUpdate || this.column.type$.value !== 'number',
options: {
items: [
numberFormatConfig(this.column),
...numberFormats.map(format => {
const data = this.column.data$.value;
return menu.action({
isSelected: data.format === format.type,
prefix: html`<span
style="font-size: var(--affine-font-base); scale: 1.2;"
>${format.symbol}</span
>`,
name: format.label,
select: () => {
if (data.format === format.type) return;
this.column.dataUpdate(() => ({
format: format.type,
}));
},
});
}),
]
: []),
],
},
}),
// Number format end
menu.group({
items: [

View File

@@ -205,47 +205,39 @@ export class DatabaseHeaderColumn extends SignalWatcher(
}
private popMenu(ele?: HTMLElement) {
const enableNumberFormatting =
this.tableViewManager.featureFlags$.value.enable_number_formatting;
popMenu(popupTargetFromElement(ele ?? this), {
options: {
items: [
inputConfig(this.column),
typeConfig(this.column),
// Number format begin
...(enableNumberFormatting
? [
menu.subMenu({
name: 'Number Format',
hide: () =>
!this.column.dataUpdate ||
this.column.type$.value !== 'number',
options: {
items: [
numberFormatConfig(this.column),
...numberFormats.map(format => {
const data = this.column.data$.value;
return menu.action({
isSelected: data.format === format.type,
prefix: html`<span
style="font-size: var(--affine-font-base); scale: 1.2;"
>${format.symbol}</span
>`,
name: format.label,
select: () => {
if (data.format === format.type) return;
this.column.dataUpdate(() => ({
format: format.type,
}));
},
});
}),
],
},
menu.subMenu({
name: 'Number Format',
hide: () =>
!this.column.dataUpdate || this.column.type$.value !== 'number',
options: {
items: [
numberFormatConfig(this.column),
...numberFormats.map(format => {
const data = this.column.data$.value;
return menu.action({
isSelected: data.format === format.type,
prefix: html`<span
style="font-size: var(--affine-font-base); scale: 1.2;"
>${format.symbol}</span
>`,
name: format.label,
select: () => {
if (data.format === format.type) return;
this.column.dataUpdate(() => ({
format: format.type,
}));
},
});
}),
]
: []),
],
},
}),
// Number format end
menu.group({
items: [

View File

@@ -337,6 +337,7 @@ export const popViewOptions = (
const reopen = () => {
popViewOptions(target, dataViewLogic);
};
let handler: ReturnType<typeof popMenu>;
const items: MenuConfig[] = [];
items.push(
menu.input({
@@ -350,16 +351,9 @@ export const popViewOptions = (
items.push(
menu.group({
items: [
menu.action({
name: 'Layout',
postfix: html` <div
style="font-size: 14px;text-transform: capitalize;"
>
${view.type}
</div>
${ArrowRightSmallIcon()}`,
select: () => {
const viewTypes = view.manager.viewMetas.map<MenuConfig>(meta => {
menu => {
const viewTypeItems = menu.renderItems(
view.manager.viewMetas.map<MenuConfig>(meta => {
return menu => {
if (!menu.search(meta.model.defaultName)) {
return;
@@ -379,10 +373,10 @@ export const popViewOptions = (
? 'var(--affine-text-emphasis-color)'
: 'var(--affine-text-secondary-color)',
});
const data: MenuButtonData = {
const buttonData: MenuButtonData = {
content: () => html`
<div
style="color:var(--affine-text-emphasis-color);width:100%;display: flex;flex-direction: column;align-items: center;justify-content: center;padding: 6px 16px;white-space: nowrap"
style="width:100%;display: flex;flex-direction: column;align-items: center;justify-content: center;padding: 6px 16px;white-space: nowrap"
>
<div style="${iconStyle}">
${renderUniLit(meta.renderer.icon)}
@@ -392,7 +386,7 @@ export const popViewOptions = (
`,
select: () => {
const id = view.manager.currentViewId$.value;
if (!id) {
if (!id || meta.type === view.type) {
return;
}
view.manager.viewChangeType(id, meta.type);
@@ -403,55 +397,35 @@ export const popViewOptions = (
const containerStyle = styleMap({
flex: '1',
});
return html` <affine-menu-button
return html`<affine-menu-button
style="${containerStyle}"
.data="${data}"
.data="${buttonData}"
.menu="${menu}"
></affine-menu-button>`;
};
});
const subHandler = popMenu(target, {
options: {
title: {
onBack: reopen,
text: 'Layout',
},
items: [
menu => {
const result = menu.renderItems(viewTypes);
if (result.length) {
return html` <div style="display: flex">${result}</div>`;
}
return html``;
},
// menu.toggleSwitch({
// name: 'Show block icon',
// on: true,
// onChange: value => {
// console.log(value);
// },
// }),
// menu.toggleSwitch({
// name: 'Show Vertical lines',
// on: true,
// onChange: value => {
// console.log(value);
// },
// }),
],
},
middleware: [
autoPlacement({
allowedPlacements: ['bottom-start', 'top-start'],
}),
offset({ mainAxis: 15, crossAxis: -162 }),
shift({ crossAxis: true }),
],
});
subHandler.menu.menuElement.style.minHeight = '550px';
},
prefix: LayoutIcon(),
}),
})
);
if (!viewTypeItems.length) {
return html``;
}
return html`
<div style="display:flex;align-items:center;gap:8px;padding:0 2px;">
<div
style="display:flex;align-items:center;color:var(--affine-icon-color);"
>
${LayoutIcon()}
</div>
<div
style="font-size:14px;line-height:22px;color:var(--affine-text-secondary-color);"
>
Layout
</div>
</div>
<div style="display:flex;gap:8px;margin-top:8px;">
${viewTypeItems}
</div>
`;
},
],
})
);
@@ -486,7 +460,6 @@ export const popViewOptions = (
],
})
);
let handler: ReturnType<typeof popMenu>;
handler = popMenu(target, {
options: {
title: {

View File

@@ -1,6 +1,6 @@
import { describe, expect, test } from 'vitest';
import { isValidUrl } from '../../utils/url.js';
import { isValidUrl, splitTextByUrl } from '../../utils/url.js';
describe('isValidUrl: determining whether a URL is valid is very complicated', () => {
test('basic case', () => {
@@ -85,3 +85,55 @@ describe('isValidUrl: determining whether a URL is valid is very complicated', (
expect(isValidUrl('http://127.0.0.1', 'http://127.0.0.1')).toEqual(true);
});
});
describe('splitTextByUrl', () => {
test('should split text and keep url part as link segment', () => {
expect(splitTextByUrl('hi - https://google.com')).toEqual([
{ text: 'hi - ' },
{
text: 'https://google.com',
link: 'https://google.com',
},
]);
});
test('should support prefixed url token without swallowing prefix text', () => {
expect(splitTextByUrl('-https://google.com')).toEqual([
{ text: '-' },
{
text: 'https://google.com',
link: 'https://google.com',
},
]);
});
test('should trim tail punctuations from url token', () => {
expect(splitTextByUrl('visit https://google.com, now')).toEqual([
{ text: 'visit ' },
{
text: 'https://google.com',
link: 'https://google.com',
},
{ text: ', now' },
]);
});
test('should convert domain token in plain text', () => {
expect(splitTextByUrl('google.com and text')).toEqual([
{
text: 'google.com',
link: 'https://google.com',
},
{ text: ' and text' },
]);
});
test('should normalize www domain token link while preserving display text', () => {
expect(splitTextByUrl('www.google.com')).toEqual([
{
text: 'www.google.com',
link: 'https://www.google.com',
},
]);
});
});

View File

@@ -2,7 +2,6 @@ import { type Store, StoreExtension } from '@blocksuite/store';
import { type Signal, signal } from '@preact/signals-core';
export interface BlockSuiteFlags {
enable_database_number_formatting: boolean;
enable_database_attachment_note: boolean;
enable_database_full_width: boolean;
enable_block_query: boolean;
@@ -28,7 +27,6 @@ export class FeatureFlagService extends StoreExtension {
static override key = 'feature-flag-server';
private readonly _flags: Signal<BlockSuiteFlags> = signal({
enable_database_number_formatting: false,
enable_database_attachment_note: false,
enable_database_full_width: false,
enable_block_query: false,

View File

@@ -95,28 +95,107 @@ export function isValidUrl(str: string, baseUrl = location.origin) {
return result?.allowed ?? false;
}
const URL_SCHEME_IN_TOKEN_REGEXP =
/(?:https?:\/\/|ftp:\/\/|sftp:\/\/|mailto:|tel:|www\.)/i;
const URL_LEADING_DELIMITER_REGEXP = /^[-([{<'"~]+/;
const URL_TRAILING_DELIMITER_REGEXP = /[)\]}>.,;:!?'"]+$/;
export type UrlTextSegment = {
text: string;
link?: string;
};
function appendUrlTextSegment(
segments: UrlTextSegment[],
segment: UrlTextSegment
) {
if (!segment.text) return;
const last = segments[segments.length - 1];
if (last && !last.link && !segment.link) {
last.text += segment.text;
return;
}
segments.push(segment);
}
function splitTokenByUrl(token: string, baseUrl: string): UrlTextSegment[] {
const schemeMatch = token.match(URL_SCHEME_IN_TOKEN_REGEXP);
const schemeIndex = schemeMatch?.index;
if (typeof schemeIndex === 'number' && schemeIndex > 0) {
return [
{ text: token.slice(0, schemeIndex) },
...splitTokenByUrl(token.slice(schemeIndex), baseUrl),
];
}
const leading = token.match(URL_LEADING_DELIMITER_REGEXP)?.[0] ?? '';
const withoutLeading = token.slice(leading.length);
const trailing =
withoutLeading.match(URL_TRAILING_DELIMITER_REGEXP)?.[0] ?? '';
const core = trailing
? withoutLeading.slice(0, withoutLeading.length - trailing.length)
: withoutLeading;
if (core && isValidUrl(core, baseUrl)) {
const segments: UrlTextSegment[] = [];
appendUrlTextSegment(segments, { text: leading });
appendUrlTextSegment(segments, { text: core, link: normalizeUrl(core) });
appendUrlTextSegment(segments, { text: trailing });
return segments;
}
return [{ text: token }];
}
/**
* Split plain text into mixed segments, where only URL segments carry link metadata.
* This is used by paste handlers so text like `example:https://google.com` keeps
* normal text while only URL parts are linkified.
*/
export function splitTextByUrl(text: string, baseUrl = location.origin) {
const chunks = text.match(/\s+|\S+/g);
if (!chunks) {
return [];
}
const segments: UrlTextSegment[] = [];
chunks.forEach(chunk => {
if (/^\s+$/.test(chunk)) {
appendUrlTextSegment(segments, { text: chunk });
return;
}
splitTokenByUrl(chunk, baseUrl).forEach(segment => {
appendUrlTextSegment(segments, segment);
});
});
return segments;
}
// https://en.wikipedia.org/wiki/Top-level_domain
const COMMON_TLDS = new Set([
'com',
'org',
'net',
'edu',
'gov',
'co',
'io',
'me',
'moe',
'mil',
'top',
'dev',
'xyz',
'info',
'cat',
'ru',
'co',
'com',
'de',
'dev',
'edu',
'eu',
'gov',
'info',
'io',
'jp',
'uk',
'me',
'mil',
'moe',
'net',
'org',
'pro',
'ru',
'top',
'uk',
'xyz',
]);
function isCommonTLD(url: URL) {

View File

@@ -15,6 +15,7 @@ import { WorkspaceSQLiteDB } from '../nbstore/v1/workspace-db-adapter';
import type { WorkspaceMeta } from '../type';
import {
getDeletedWorkspacesBasePath,
getSpaceBasePath,
getSpaceDBPath,
getWorkspaceBasePathV1,
getWorkspaceMeta,
@@ -96,6 +97,33 @@ export async function storeWorkspaceMeta(
}
}
export async function listLocalWorkspaceIds(): Promise<string[]> {
const localWorkspaceBasePath = path.join(
await getSpaceBasePath('workspace'),
'local'
);
if (!(await fs.pathExists(localWorkspaceBasePath))) {
return [];
}
const entries = await fs.readdir(localWorkspaceBasePath);
const ids = await Promise.all(
entries.map(async entry => {
const workspacePath = path.join(localWorkspaceBasePath, entry);
const stat = await fs.stat(workspacePath).catch(() => null);
if (!stat?.isDirectory()) {
return null;
}
if (!(await fs.pathExists(path.join(workspacePath, 'storage.db')))) {
return null;
}
return entry;
})
);
return ids.filter((id): id is string => typeof id === 'string');
}
type WorkspaceDocMeta = {
id: string;
name: string;

View File

@@ -3,6 +3,7 @@ import {
deleteBackupWorkspace,
deleteWorkspace,
getDeletedWorkspaces,
listLocalWorkspaceIds,
trashWorkspace,
} from './handlers';
@@ -18,4 +19,5 @@ export const workspaceHandlers = {
return getDeletedWorkspaces();
},
deleteBackupWorkspace: async (id: string) => deleteBackupWorkspace(id),
listLocalWorkspaceIds: async () => listLocalWorkspaceIds(),
};

View File

@@ -33,6 +33,43 @@ afterAll(() => {
});
describe('workspace db management', () => {
test('list local workspace ids', async () => {
const { listLocalWorkspaceIds } =
await import('@affine/electron/helper/workspace/handlers');
const validWorkspaceId = v4();
const noDbWorkspaceId = v4();
const fileEntry = 'README.txt';
const validWorkspacePath = path.join(
appDataPath,
'workspaces',
'local',
validWorkspaceId
);
const noDbWorkspacePath = path.join(
appDataPath,
'workspaces',
'local',
noDbWorkspaceId
);
const nonDirectoryPath = path.join(
appDataPath,
'workspaces',
'local',
fileEntry
);
await fs.ensureDir(validWorkspacePath);
await fs.ensureFile(path.join(validWorkspacePath, 'storage.db'));
await fs.ensureDir(noDbWorkspacePath);
await fs.outputFile(nonDirectoryPath, 'not-a-workspace');
const ids = await listLocalWorkspaceIds();
expect(ids).toContain(validWorkspaceId);
expect(ids).not.toContain(noDbWorkspaceId);
expect(ids).not.toContain(fileEntry);
});
test('trash workspace', async () => {
const { trashWorkspace } =
await import('@affine/electron/helper/workspace/handlers');

View File

@@ -17,7 +17,7 @@ let package = Package(
],
dependencies: [
.package(path: "../AffineResources"),
.package(url: "https://github.com/RevenueCat/purchases-ios-spm.git", from: "5.56.1"),
.package(url: "https://github.com/RevenueCat/purchases-ios-spm.git", from: "5.58.0"),
],
targets: [
.target(

View File

@@ -48,15 +48,44 @@ import { WorkspaceImpl } from '../../workspace/impls/workspace';
import { getWorkspaceProfileWorker } from './out-worker';
export const LOCAL_WORKSPACE_LOCAL_STORAGE_KEY = 'affine-local-workspace';
export const LOCAL_WORKSPACE_GLOBAL_STATE_KEY =
'workspace-engine:local-workspace-ids:v1';
const LOCAL_WORKSPACE_CHANGED_BROADCAST_CHANNEL_KEY =
'affine-local-workspace-changed';
const logger = new DebugLogger('local-workspace');
export function getLocalWorkspaceIds(): string[] {
type GlobalStateStorageLike = {
ready: Promise<void>;
get<T>(key: string): T | undefined;
set<T>(key: string, value: T): void;
};
function normalizeWorkspaceIds(ids: unknown): string[] {
if (!Array.isArray(ids)) {
return [];
}
return ids.filter((id): id is string => typeof id === 'string');
}
function getElectronGlobalStateStorage(): GlobalStateStorageLike | null {
if (!BUILD_CONFIG.isElectron) {
return null;
}
const sharedStorage = (
globalThis as {
__sharedStorage?: { globalState?: GlobalStateStorageLike };
}
).__sharedStorage;
return sharedStorage?.globalState ?? null;
}
function getLegacyLocalWorkspaceIds(): string[] {
try {
return JSON.parse(
localStorage.getItem(LOCAL_WORKSPACE_LOCAL_STORAGE_KEY) ?? '[]'
return normalizeWorkspaceIds(
JSON.parse(
localStorage.getItem(LOCAL_WORKSPACE_LOCAL_STORAGE_KEY) ?? '[]'
)
);
} catch (e) {
logger.error('Failed to get local workspace ids', e);
@@ -64,21 +93,98 @@ export function getLocalWorkspaceIds(): string[] {
}
}
export function getLocalWorkspaceIds(): string[] {
const globalState = getElectronGlobalStateStorage();
if (globalState) {
const value = globalState.get(LOCAL_WORKSPACE_GLOBAL_STATE_KEY);
if (value !== undefined) {
return normalizeWorkspaceIds(value);
}
}
return getLegacyLocalWorkspaceIds();
}
export function setLocalWorkspaceIds(
idsOrUpdater: string[] | ((ids: string[]) => string[])
) {
localStorage.setItem(
LOCAL_WORKSPACE_LOCAL_STORAGE_KEY,
JSON.stringify(
typeof idsOrUpdater === 'function'
? idsOrUpdater(getLocalWorkspaceIds())
: idsOrUpdater
)
const next = normalizeWorkspaceIds(
typeof idsOrUpdater === 'function'
? idsOrUpdater(getLocalWorkspaceIds())
: idsOrUpdater
);
const deduplicated = [...new Set(next)];
const globalState = getElectronGlobalStateStorage();
if (globalState) {
globalState.set(LOCAL_WORKSPACE_GLOBAL_STATE_KEY, deduplicated);
return;
}
try {
localStorage.setItem(
LOCAL_WORKSPACE_LOCAL_STORAGE_KEY,
JSON.stringify(deduplicated)
);
} catch (e) {
logger.error('Failed to set local workspace ids', e);
}
}
class LocalWorkspaceFlavourProvider implements WorkspaceFlavourProvider {
constructor(private readonly framework: FrameworkProvider) {}
constructor(private readonly framework: FrameworkProvider) {
if (BUILD_CONFIG.isElectron) {
void this.ensureWorkspaceIdsMigrated();
}
}
private migration: Promise<void> | null = null;
private ensureWorkspaceIdsMigrated() {
if (!BUILD_CONFIG.isElectron) {
return;
}
if (this.migration) {
return;
}
this.migration = (async () => {
const electronApi = this.framework.get(DesktopApiService);
await electronApi.sharedStorage.globalState.ready;
const persistedIds = normalizeWorkspaceIds(
electronApi.sharedStorage.globalState.get(
LOCAL_WORKSPACE_GLOBAL_STATE_KEY
)
);
const legacyIds = getLegacyLocalWorkspaceIds();
let scannedIds: string[] = [];
try {
scannedIds =
await electronApi.handler.workspace.listLocalWorkspaceIds();
} catch (e) {
logger.error('Failed to scan local workspace ids', e);
}
setLocalWorkspaceIds(currentIds => {
return [
...new Set([
...currentIds,
...persistedIds,
...legacyIds,
...scannedIds,
]),
];
});
})()
.catch(e => {
logger.error('Failed to migrate local workspace ids', e);
})
.finally(() => {
this.notifyChannel.postMessage(null);
});
}
readonly flavour = 'local';
readonly notifyChannel = new BroadcastChannel(
@@ -242,6 +348,9 @@ class LocalWorkspaceFlavourProvider implements WorkspaceFlavourProvider {
);
isRevalidating$ = new LiveData(false);
revalidate(): void {
if (BUILD_CONFIG.isElectron) {
void this.ensureWorkspaceIdsMigrated();
}
// notify livedata to re-scan workspaces
this.notifyChannel.postMessage(null);
}

View File

@@ -5,8 +5,8 @@ import { GlobalState } from '../storage';
import { WorkspaceFlavoursProvider } from '../workspace';
import { CloudWorkspaceFlavoursProvider } from './impls/cloud';
import {
LOCAL_WORKSPACE_LOCAL_STORAGE_KEY,
LocalWorkspaceFlavoursProvider,
setLocalWorkspaceIds,
} from './impls/local';
export { base64ToUint8Array, uint8ArrayToBase64 } from './utils/base64';
@@ -25,12 +25,5 @@ export function configureBrowserWorkspaceFlavours(framework: Framework) {
* Used after copying sqlite database file to appdata folder
*/
export function _addLocalWorkspace(id: string) {
const allWorkspaceIDs: string[] = JSON.parse(
localStorage.getItem(LOCAL_WORKSPACE_LOCAL_STORAGE_KEY) ?? '[]'
);
allWorkspaceIDs.push(id);
localStorage.setItem(
LOCAL_WORKSPACE_LOCAL_STORAGE_KEY,
JSON.stringify(allWorkspaceIDs)
);
setLocalWorkspaceIds(ids => (ids.includes(id) ? ids : [...ids, id]));
}