Skip to content

Commit 7e6af83

Browse files
authored
Fix: Tab not showing after refresh + store unsaved changes (#1164)
1 parent c066a0d commit 7e6af83

7 files changed

Lines changed: 139 additions & 63 deletions

File tree

web/client/src/context/editor.ts

Lines changed: 69 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { type LineageColumn } from '@api/client'
22
import { type TableColumn, type TableRow } from '@components/table/help'
3-
import { uid } from '@utils/index'
3+
import { isFalse, isNil, isNotNil, uid } from '@utils/index'
44
import { create } from 'zustand'
55
import useLocalStorage from '~/hooks/useLocalStorage'
66
import { type ErrorKey, type ErrorIDE } from '~/library/pages/ide/context'
@@ -17,9 +17,14 @@ export interface Lineage {
1717
columns?: Record<string, LineageColumn>
1818
}
1919

20+
export interface StoredTab {
21+
id?: ID
22+
content?: string
23+
}
24+
2025
interface EditorStore {
21-
storedTabsId?: ID
22-
storedTabsIds: ID[]
26+
storedTabId?: ID
27+
storedTabs: StoredTab[]
2328
tabs: Map<ModelFile, EditorTab>
2429
tab?: EditorTab
2530
engine: Worker
@@ -34,6 +39,7 @@ interface EditorStore {
3439
replaceTab: (from: EditorTab, to: EditorTab) => void
3540
updateStoredTabsIds: () => void
3641
addTab: (tab: EditorTab) => void
42+
addTabs: (tabs: EditorTab[]) => void
3743
closeTab: (file: ModelFile) => void
3844
createTab: (file?: ModelFile) => EditorTab
3945
setDialects: (dialects: Dialect[]) => void
@@ -65,17 +71,23 @@ export interface EditorTab {
6571
el?: HTMLElement
6672
}
6773

68-
const [getStoredTabs, setStoredTabs] = useLocalStorage<{ ids: ID[]; id: ID }>(
69-
'tabs',
70-
)
74+
const [getStoredTabs, setStoredTabs] = useLocalStorage<{
75+
tabs: StoredTab[]
76+
id?: ID
77+
}>('tabs')
7178

79+
const { tabs: storedTabs = [], id: storedTabId } = getStoredTabs() ?? {}
7280
const initialFile = createLocalFile()
7381
const initialTab: EditorTab = createTab(initialFile)
74-
const initialTabs = new Map([[initialFile, initialTab]])
82+
const initialTabs = new Map(
83+
storedTabs.length > 0 && isNotNil(storedTabId)
84+
? []
85+
: [[initialFile, initialTab]],
86+
)
7587

7688
export const useStoreEditor = create<EditorStore>((set, get) => ({
77-
storedTabsIds: getStoredTabs()?.ids ?? [],
78-
storedTabsId: getStoredTabs()?.id,
89+
storedTabs,
90+
storedTabId,
7991
tab: initialTab,
8092
tabs: initialTabs,
8193
engine: sqlglotWorker,
@@ -101,19 +113,43 @@ export const useStoreEditor = create<EditorStore>((set, get) => ({
101113
},
102114
updateStoredTabsIds() {
103115
const s = get()
104-
const id = s.tab?.file.id
105-
const ids = Array.from(get().tabs.values())
106-
.filter(tab => tab.file.isRemote)
107-
.map(tab => tab.file.id)
116+
117+
if (isNil(s.tab)) {
118+
setStoredTabs({
119+
id: undefined,
120+
tabs: [],
121+
})
122+
123+
set(() => ({
124+
storedTabId: undefined,
125+
storedTabs: [],
126+
}))
127+
128+
return
129+
}
130+
131+
const tabs: StoredTab[] = []
132+
133+
for (const tab of s.tabs.values()) {
134+
if (isFalse(tab.file.isChanged) && tab.file.isLocal) continue
135+
136+
tabs.push({
137+
id: tab.file.id,
138+
content: tab.file.isChanged ? tab.file.content : undefined,
139+
})
140+
}
141+
142+
const id =
143+
s.tab.file.isChanged || s.tab.file.isRemote ? s.tab.file.id : undefined
108144

109145
setStoredTabs({
110146
id,
111-
ids,
147+
tabs,
112148
})
113149

114150
set(() => ({
115-
storedTabsId: id,
116-
storedTabsIds: ids,
151+
storedTabId: id,
152+
storedTabs: tabs,
117153
}))
118154
},
119155
refreshTab() {
@@ -129,7 +165,22 @@ export const useStoreEditor = create<EditorStore>((set, get) => ({
129165
}))
130166
},
131167
selectTab(tab) {
168+
const s = get()
169+
132170
set(() => ({ tab }))
171+
172+
s.updateStoredTabsIds()
173+
},
174+
addTabs(tabs) {
175+
const s = get()
176+
177+
for (const tab of tabs) {
178+
s.tabs.set(tab.file, tab)
179+
}
180+
181+
set(() => ({
182+
tabs: new Map(s.tabs),
183+
}))
133184
},
134185
addTab(tab) {
135186
const s = get()
@@ -204,8 +255,9 @@ function createTab(file: ModelFile = createLocalFile()): EditorTab {
204255
}
205256
}
206257

207-
function createLocalFile(): ModelFile {
258+
export function createLocalFile(id?: ID): ModelFile {
208259
return new ModelFile({
260+
id,
209261
name: '',
210262
path: '',
211263
content:

web/client/src/context/project.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
11
import { create } from 'zustand'
22
import { ModelDirectory, type ModelFile } from '../models'
3-
import { type Directory } from '~/api/client'
43

54
interface ProjectStore {
65
project: ModelDirectory
7-
setProject: (project?: Directory) => void
6+
setProject: (project?: ModelDirectory) => void
87
files: Map<ID, ModelFile>
98
setFiles: (files: ModelFile[]) => void
109
selectedFile?: ModelFile
@@ -17,7 +16,7 @@ export const useStoreProject = create<ProjectStore>((set, get) => ({
1716
files: new Map(),
1817
setProject(project) {
1918
set(() => ({
20-
project: new ModelDirectory(project),
19+
project,
2120
}))
2221
},
2322
setFiles(files) {

web/client/src/library/components/editor/EditorCode.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import CodeMirror from '@uiw/react-codemirror'
33
import { type KeyBinding, keymap } from '@codemirror/view'
44
import { type Extension } from '@codemirror/state'
55
import { useApiFileByPath } from '~/api'
6-
import { debounceAsync, isNil, isStringNotEmpty } from '~/utils'
6+
import { debounceAsync, isNil } from '~/utils'
77
import { isCancelledError } from '@tanstack/react-query'
88
import { useStoreContext } from '~/context/context'
99
import { useStoreEditor } from '~/context/editor'
@@ -133,7 +133,7 @@ function CodeEditorRemoteFile({
133133
const tempFile = files.get(path)
134134

135135
if (tempFile == null) return
136-
if (isStringNotEmpty(tempFile.content)) {
136+
if (tempFile.isSynced) {
137137
setFile(tempFile)
138138
return
139139
}

web/client/src/library/components/editor/extensions/SQLMeshDialect.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,8 +72,7 @@ export const SQLMeshDialect: ExtensionSQLMeshDialect = function SQLMeshDialect(
7272
const keywordFrom = ctx.matchBefore(/from.+/i)
7373
const keywordKind = ctx.matchBefore(/kind.+/i)
7474
const keywordDialect = ctx.matchBefore(/dialect.+/i)
75-
const matchModels =
76-
text.match(/MODEL \((?:[^)(]+|\((?:[^)(]+|\([^)(]*\))*\))*\)/g) ?? []
75+
const matchModels = text.match(/MODEL \(([\s\S]+?)\);/g) ?? []
7776
const isInsideModel = matchModels
7877
.filter(str => str.includes(match))
7978
.map<[number, number]>(str => [

web/client/src/library/pages/ide/IDE.tsx

Lines changed: 38 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,15 @@ import {
1717
type PlanTasks,
1818
} from '../../../context/plan'
1919
import { useChannelEvents } from '../../../api/channels'
20-
import { debounceAsync, isFalse, isObject, isObjectEmpty } from '~/utils'
20+
import {
21+
debounceAsync,
22+
isArrayEmpty,
23+
isFalse,
24+
isNil,
25+
isNotNil,
26+
isObject,
27+
isObjectEmpty,
28+
} from '~/utils'
2129
import { useStoreContext } from '~/context/context'
2230
import { ArrowLongRightIcon } from '@heroicons/react/24/solid'
2331
import { EnumSize, EnumVariant } from '~/types/enum'
@@ -29,11 +37,12 @@ import { type Model } from '@api/client'
2937
import { Button } from '@components/button/Button'
3038
import { Divider } from '@components/divider/Divider'
3139
import Container from '@components/container/Container'
32-
import { useStoreEditor } from '@context/editor'
40+
import { useStoreEditor, createLocalFile } from '@context/editor'
3341
import { type ModelFile } from '@models/file'
3442
import ModalConfirmation, {
3543
type Confirmation,
3644
} from '@components/modal/ModalConfirmation'
45+
import { ModelDirectory } from '@models/directory'
3746

3847
const ReportErrors = lazy(
3948
async () => await import('../../components/report/ReportErrors'),
@@ -73,11 +82,11 @@ export default function PageIDE(): JSX.Element {
7382
const setProject = useStoreProject(s => s.setProject)
7483
const setFiles = useStoreProject(s => s.setFiles)
7584

76-
const storedTabsIds = useStoreEditor(s => s.storedTabsIds)
77-
const storedTabsId = useStoreEditor(s => s.storedTabsId)
85+
const storedTabs = useStoreEditor(s => s.storedTabs)
86+
const storedTabId = useStoreEditor(s => s.storedTabId)
7887
const selectTab = useStoreEditor(s => s.selectTab)
7988
const createTab = useStoreEditor(s => s.createTab)
80-
const addTab = useStoreEditor(s => s.addTab)
89+
const addTabs = useStoreEditor(s => s.addTabs)
8190

8291
const subscribe = useChannelEvents()
8392

@@ -125,7 +134,14 @@ export default function PageIDE(): JSX.Element {
125134
})
126135

127136
void debouncedGetFiles().then(({ data }) => {
128-
setProject(data)
137+
if (isNil(data)) return
138+
139+
const project = new ModelDirectory(data)
140+
const files = project.allFiles
141+
142+
setFiles(files)
143+
restoreEditorTabsFromSaved(files)
144+
setProject(project)
129145
})
130146

131147
return () => {
@@ -152,13 +168,6 @@ export default function PageIDE(): JSX.Element {
152168
}
153169
}, [location])
154170

155-
useEffect(() => {
156-
const files = project?.allFiles ?? []
157-
158-
setFiles(files)
159-
restoreEditorTabsFromSaved(files)
160-
}, [project])
161-
162171
useEffect(() => {
163172
if (dataEnvironments == null || isObjectEmpty(dataEnvironments)) return
164173

@@ -228,17 +237,23 @@ export default function PageIDE(): JSX.Element {
228237
}
229238

230239
function restoreEditorTabsFromSaved(files: ModelFile[]): void {
231-
files.forEach(file => {
232-
if (storedTabsIds.includes(file.id)) {
233-
const tab = createTab(file)
234-
235-
if (storedTabsId === file.id) {
236-
selectTab(tab)
237-
} else {
238-
addTab(tab)
239-
}
240-
}
240+
if (isArrayEmpty(storedTabs)) return
241+
242+
const tabs = storedTabs.map(({ id, content }) => {
243+
const file = files.find(file => file.id === id) ?? createLocalFile(id)
244+
const storedTab = createTab(file)
245+
246+
storedTab.file.content = content ?? storedTab.file.content ?? ''
247+
248+
return storedTab
241249
})
250+
const tab = tabs.find(tab => tab.file.id === storedTabId)
251+
252+
addTabs(tabs)
253+
254+
if (isNotNil(tab)) {
255+
selectTab(tab)
256+
}
242257
}
243258

244259
function closeModalConfirmation(confirmation?: Confirmation): void {

web/client/src/models/file.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { type File, FileType } from '../api/client'
22
import { type ModelDirectory } from './directory'
33
import { type InitialArtifact, ModelArtifact } from './artifact'
4-
import { isStringEmptyOrNil, toUniqueName } from '@utils/index'
4+
import { isFalse, isStringEmptyOrNil, toUniqueName } from '@utils/index'
55

66
export const EnumFileExtensions = {
77
SQL: '.sql',
@@ -57,8 +57,12 @@ export class ModelFile extends ModelArtifact<InitialFile> {
5757
this._type = newType ?? getFileType(this.path)
5858
}
5959

60+
get isSynced(): boolean {
61+
return isFalse(isStringEmptyOrNil(this._content))
62+
}
63+
6064
get isEmpty(): boolean {
61-
return this.content === ''
65+
return isStringEmptyOrNil(this.content)
6266
}
6367

6468
get isSupported(): boolean {
@@ -92,8 +96,16 @@ export class ModelFile extends ModelArtifact<InitialFile> {
9296
}
9397

9498
updateContent(newContent: string = ''): void {
99+
// When modifying a file locally, we only modify the content.
100+
// Therefore, if we have content but the variable "_content" is empty,
101+
// it is likely because we restored the file content from localStorage.
102+
// After updating "_content", we still want to retain the content
103+
// because it is unsaved changes.
104+
if (this.isSynced) {
105+
this.content = newContent
106+
}
107+
95108
this._content = newContent
96-
this.content = newContent
97109
}
98110

99111
update(newFile?: File): void {

web/client/src/models/initial.ts

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { uid } from '@utils/index'
1+
import { isNotNil, uid } from '@utils/index'
22

33
type Initial<T extends object> = T & { id?: ID }
44
type InitialWithId<T extends object> = T & { id: ID }
@@ -9,19 +9,18 @@ export class ModelInitial<T extends object = any> {
99
isModel = true
1010

1111
constructor(initial: Initial<T> | InitialWithId<T>) {
12-
this._initial =
13-
'id' in initial
14-
? (initial as InitialWithId<T>)
15-
: new Proxy<InitialWithId<T>>(
16-
Object.assign(initial ?? {}, {
17-
id: uid(),
18-
}),
19-
{
20-
set() {
21-
throw new Error('Cannot change initial file')
22-
},
12+
this._initial = isNotNil(initial?.id)
13+
? (initial as InitialWithId<T>)
14+
: new Proxy<InitialWithId<T>>(
15+
Object.assign(initial ?? {}, {
16+
id: uid(),
17+
}),
18+
{
19+
set() {
20+
throw new Error('Cannot change initial file')
2321
},
24-
)
22+
},
23+
)
2524
}
2625

2726
get initial(): InitialWithId<T> {

0 commit comments

Comments
 (0)