Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

POC of local storage manager #17386

Draft
wants to merge 1 commit into
base: development
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
212 changes: 212 additions & 0 deletions app/src/lib/local-storage.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { ImageDiffType } from '../models/diff'
import { parseEnumValue } from './enum'
import { fatalError } from './fatal-error'
import { ILastThankYou } from '../models/last-thank-you'

/**
* Returns the value for the provided key from local storage interpreted as a
Expand Down Expand Up @@ -238,3 +241,212 @@ export function setObject(key: string, value: object) {
const rawData = JSON.stringify(value)
localStorage.setItem(key, rawData)
}

const LocalStorageKeys = [
'askToMoveToApplicationsFolder',
'commit-spellcheck-enabled',
'commit-summary-width',
'confirmCheckoutCommit',
'confirmDiscardChanges',
'confirmDiscardChangesPermanentlyKey',
'confirmDiscardStash',
'confirmForcePush',
'confirmRepoRemoval',
'confirmUndoCommit',
'externalEditor',
'hide-whitespace-in-changes-diff',
'hide-whitespace-in-diff',
'hide-whitespace-in-pull-request-diff',
'image-diff-type',
'last-selected-repository-id',
'version-and-users-of-last-thank-you',
'pull-request-files-width',
'pull-request-suggested-next-action-key',
'recently-selected-repositories',
'enable-repository-indicators',
'showCommitLengthWarning',
'shell',
'sidebar-width',
'stashed-files-width',
'uncommittedChangesStrategyKind',
] as const

export type LocalStorageKey = typeof LocalStorageKeys[number]

const LocalStorageDefaults: Record<
LocalStorageKey,
boolean | number | object | null
> = {
askToMoveToApplicationsFolder: true,
'commit-spellcheck-enabled': true,
'commit-summary-width': 250,
confirmCheckoutCommit: true,
confirmDiscardChanges: true,
confirmDiscardChangesPermanentlyKey: true,
confirmDiscardStash: true,
confirmForcePush: true,
confirmRepoRemoval: true,
confirmUndoCommit: true,
externalEditor: null,
'hide-whitespace-in-changes-diff': false,
'hide-whitespace-in-diff': false,
'hide-whitespace-in-pull-request-diff': false,
'image-diff-type': ImageDiffType.TwoUp,
'last-selected-repository-id': 0,
Comment on lines +287 to +295
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Highlights our inconsistency in the past of key naming 😄

'version-and-users-of-last-thank-you': null,
'pull-request-files-width': 250,
'pull-request-suggested-next-action-key': 0,
'recently-selected-repositories': 0,
'enable-repository-indicators': 0,
showCommitLengthWarning: false,
shell: 0,
'sidebar-width': 250,
'stashed-files-width': 250,
uncommittedChangesStrategyKind: 0,
}

export class LocalStorageManager {
private readonly storage = new Map<
string,
boolean | string | object | number | null
>()

public constructor() {
this.intialize()
}

public get<T extends typeof LocalStorageDefaults[LocalStorageKey]>(
key: LocalStorageKey
): T {
const value = this.storage.get(key)

if (value === undefined) {
fatalError(`Unknown key: ${key}`)
}

if (typeof value !== typeof LocalStorageDefaults[key]) {
fatalError(`Incorrect Type: ${key}`)
}

// Not sure if this casting is safe..
return value as T
Comment on lines +331 to +332
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is bad.. but didn't know how to properly do the typing stuff on this. unless I did a getBoolean, getNumber, getObject`... but, didn't want to explore more because I was just trying to do quick/dirty POC right now.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this cast should be safe but do we need generics in this function? Unless I'm missing something, I would define a type for the types of values that can be stored:

type LocalStorageValue = string | number | boolean | object | null

and then use that both in the map of default values:

const LocalStorageDefaults: Record<LocalStorageKey, LocalStorageValue> = { ... }

And in this function:

  public get(key: LocalStorageKey): LocalStorageValue {
    const value: LocalStorageValue | undefined = this.storage.get(key)

    if (value === undefined) {
      fatalError(`Unknown key: ${key}`)
    }

    if (typeof value !== typeof LocalStorageDefaults[key]) {
      fatalError(`Incorrect Type: ${key}`)
    }

    return value
  }

And in set (unless for some reason we can't set a value to null):

public set(key: LocalStorageKey, value: LocalStorageValue) { ... }

Does this make sense? 🤔

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, reading https://github.com/desktop/desktop/pull/17386/files#r1324939041 now I understand why you ask and why my approach wouldn't work either 😂

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AFAIK you cannot narrow the type down more than you already do, because we would need something like:

  public get<T extends typeof LocalStorageDefaults[key]>(
    key: LocalStorageKey
  ): T {

Which is impossible because the typing part cannot use anything from the runtime part.

}

public set(key: LocalStorageKey, value: boolean | string | object | number) {
switch (typeof value) {
case 'boolean':
setBoolean(key, value)
break
case 'number':
setNumber(key, value)
break
case 'object':
setObject(key, value)
break
default:
fatalError(`Unexpected default value type: ${typeof value}`)
}

this.storage.set(key, value)
}

private intialize() {
this.applySchema()

LocalStorageKeys.forEach(key => {
this.initializeValue(key)
})
}

/**
* We are implementing a versioning of our localStorage config so that we can
* migrate new users to new app defaults while keeping existing users on their
* current configuration. Starting the versioning at 1.
*
* Another way to say this, is this provides a way to override the default
* values. If a config is not set in this versioning, the user will receive the
* default values defined in `LocalStorageDefaults`
*/
private applySchema() {
const latestSchemaVersion = 1

// If config version doesn't exist (new installation), set it to the latest.
if (getNumber('config-version') === undefined) {
setNumber('config-version', latestSchemaVersion)
}

const userConfig = getNumber('config-version', latestSchemaVersion)

if (userConfig <= 1) {
this.overrideValue('enable-repository-indicators', true)
this.overrideValue('showCommitLengthWarning', true)
}
}

private overrideValue(
key: LocalStorageKey,
value: boolean | string | object | number
) {
switch (typeof value) {
case 'boolean':
if (getBoolean(key) === undefined) {
setBoolean(key, value)
}
break
case 'number':
if (getNumber(key) === undefined) {
setNumber(key, value)
}
break
case 'object':
if (getObject(key) === undefined) {
setObject(key, value)
}
break
default:
fatalError(`Unexpected default value type: ${typeof value}`)
}
}

private initializeValue(key: LocalStorageKey) {
const defaultValue: boolean | number | object | null =
LocalStorageDefaults[key]

switch (typeof defaultValue) {
case 'boolean':
const booleanValue = getBoolean(key, defaultValue)
this.storage.set(key, booleanValue)
break
case 'number':
const numberValue = getNumber(key, defaultValue)
this.storage.set(key, numberValue)
break
case 'object':
this.intializeObjectValue(key, defaultValue)
break
default:
fatalError(`Unexpected default value type: ${typeof defaultValue}`)
}
}

private intializeObjectValue(
key: LocalStorageKey,
defaultValue: object | null
) {
if (key === 'image-diff-type') {
const imageDiffTypeStoredValue = localStorage.getItem(key)
const value =
imageDiffTypeStoredValue === null
? defaultValue
: parseInt(imageDiffTypeStoredValue)
this.storage.set(key, value)
return
}

if (key === 'version-and-users-of-last-thank-you') {
const storedValue = getObject<ILastThankYou>(key) ?? null
this.storage.set(key, storedValue)
return
}
}
}
20 changes: 5 additions & 15 deletions app/src/lib/stores/app-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,7 @@ import {
getObject,
setObject,
getFloatNumber,
LocalStorageManager,
} from '../local-storage'
import { ExternalEditorError, suggestedExternalEditor } from '../editors/shared'
import { ApiRepositoriesStore } from './api-repositories-store'
Expand Down Expand Up @@ -349,15 +350,13 @@ const stashedFilesWidthConfigKey: string = 'stashed-files-width'
const defaultPullRequestFileListWidth: number = 250
const pullRequestFileListConfigKey: string = 'pull-request-files-width'

const askToMoveToApplicationsFolderDefault: boolean = true
const confirmRepoRemovalDefault: boolean = true
const confirmDiscardChangesDefault: boolean = true
const confirmDiscardChangesPermanentlyDefault: boolean = true
const confirmDiscardStashDefault: boolean = true
const confirmCheckoutCommitDefault: boolean = true
const askForConfirmationOnForcePushDefault = true
const confirmUndoCommitDefault: boolean = true
const askToMoveToApplicationsFolderKey: string = 'askToMoveToApplicationsFolder'
const confirmRepoRemovalKey: string = 'confirmRepoRemoval'
const confirmDiscardChangesKey: string = 'confirmDiscardChanges'
const confirmDiscardStashKey: string = 'confirmDiscardStash'
Expand Down Expand Up @@ -465,8 +464,6 @@ export class AppStore extends TypedBaseStore<IAppState> {
private isUpdateAvailableBannerVisible: boolean = false
private isUpdateShowcaseVisible: boolean = false

private askToMoveToApplicationsFolderSetting: boolean =
askToMoveToApplicationsFolderDefault
private askForConfirmationOnRepositoryRemoval: boolean =
confirmRepoRemovalDefault
private confirmDiscardChanges: boolean = confirmDiscardChangesDefault
Expand Down Expand Up @@ -546,7 +543,8 @@ export class AppStore extends TypedBaseStore<IAppState> {
private readonly pullRequestCoordinator: PullRequestCoordinator,
private readonly repositoryStateCache: RepositoryStateCache,
private readonly apiRepositoriesStore: ApiRepositoriesStore,
private readonly notificationsStore: NotificationsStore
private readonly notificationsStore: NotificationsStore,
private readonly localStorageManager: LocalStorageManager
) {
super()

Expand Down Expand Up @@ -968,7 +966,7 @@ export class AppStore extends TypedBaseStore<IAppState> {
isUpdateShowcaseVisible: this.isUpdateShowcaseVisible,
currentBanner: this.currentBanner,
askToMoveToApplicationsFolderSetting:
this.askToMoveToApplicationsFolderSetting,
this.localStorageManager.get('image-diff-type'),
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is highlighting my other comment that the casting of the type is bad. :D Because the image-diff-type is a object.. but this doesn't complain even tho askToMoveToApplicationsFolderSetting is a boolean.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In this case image-diff-type is a number, right? Hence why it works at runtime, because it's able to convert it to a boolean.

However, get is inferring T from the required return type, which is a boolean thanks to askToMoveToApplicationsFolderSetting, instead of inferring it from the type of the value for the given image-diff-type key, which I think can't be done 😞

Copy link
Member

@sergiou87 sergiou87 Sep 14, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This idea made the trick:

  1. First remove the Record… typing from the defaults object:
const LocalStorageDefaults = { ... } as const
  1. Make get generic parameter to infer the key type (which should be the string itself) and then use that to parameterize the return type:
  public get<
    V extends LocalStorageKey,
    T extends typeof LocalStorageDefaults[V]
  >(key: V): T {

The only issue I see with this is that for nullable values, you need to be explicit about the type in the defaults:

  'version-and-users-of-last-thank-you': null as object | null,

This is what this line looks like now:
image
image

Finally, in order to make sure LocalStorageDefaults is exhaustive and has all keys, we could do something like…

const TypeSafeLocalStorageDefaults: Record<
  LocalStorageKey,
  string | object | number | boolean | null
> = LocalStorageDefaults

Which should do the check for us at build time. A bit hacky, but sounds like the best option to me 🤔

askForConfirmationOnRepositoryRemoval:
this.askForConfirmationOnRepositoryRemoval,
askForConfirmationOnDiscardChanges: this.confirmDiscardChanges,
Expand Down Expand Up @@ -2082,11 +2080,6 @@ export class AppStore extends TypedBaseStore<IAppState> {
// TODO: Initiliaze here for now... maybe move to dialog mounting
this.updatePullRequestResizableConstraints()

this.askToMoveToApplicationsFolderSetting = getBoolean(
askToMoveToApplicationsFolderKey,
askToMoveToApplicationsFolderDefault
)

this.askForConfirmationOnRepositoryRemoval = getBoolean(
confirmRepoRemovalKey,
confirmRepoRemovalDefault
Expand Down Expand Up @@ -5403,11 +5396,8 @@ export class AppStore extends TypedBaseStore<IAppState> {
public _setAskToMoveToApplicationsFolderSetting(
value: boolean
): Promise<void> {
this.askToMoveToApplicationsFolderSetting = value

setBoolean(askToMoveToApplicationsFolderKey, value)
this.localStorageManager.set('askToMoveToApplicationsFolder', value)
this.emitUpdate()

return Promise.resolve()
}

Expand Down
6 changes: 5 additions & 1 deletion app/src/ui/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ import { migrateRendererGUID } from '../lib/get-renderer-guid'
import { initializeRendererNotificationHandler } from '../lib/notifications/notification-handler'
import { Grid } from 'react-virtualized'
import { NotificationsDebugStore } from '../lib/stores/notifications-debug-store'
import { LocalStorageManager } from '../lib/local-storage'

if (__DEV__) {
installDevGlobals()
Expand Down Expand Up @@ -274,6 +275,8 @@ const notificationsDebugStore = new NotificationsDebugStore(
pullRequestCoordinator
)

const localStorageManger = new LocalStorageManager()

const appStore = new AppStore(
gitHubUserStore,
cloningRepositoriesStore,
Expand All @@ -285,7 +288,8 @@ const appStore = new AppStore(
pullRequestCoordinator,
repositoryStateManager,
apiRepositoriesStore,
notificationsStore
notificationsStore,
localStorageManger
)

appStore.onDidUpdate(state => {
Expand Down
6 changes: 5 additions & 1 deletion app/test/unit/app-store-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import { AppFileStatusKind } from '../../src/models/status'
import { ManualConflictResolution } from '../../src/models/manual-conflict-resolution'
import { AliveStore } from '../../src/lib/stores/alive-store'
import { NotificationsStore } from '../../src/lib/stores/notifications-store'
import { LocalStorageManager } from '../../src/lib/local-storage'

// enable mocked version
jest.mock('../../src/lib/window-state')
Expand Down Expand Up @@ -92,6 +93,8 @@ describe('AppStore', () => {
)
notificationsStore.setNotificationsEnabled(false)

const localStorageManager = new LocalStorageManager()

const appStore = new AppStore(
githubUserStore,
new CloningRepositoriesStore(),
Expand All @@ -103,7 +106,8 @@ describe('AppStore', () => {
pullRequestCoordinator,
repositoryStateManager,
apiRepositoriesStore,
notificationsStore
notificationsStore,
localStorageManager
)

return { appStore, repositoriesStore }
Expand Down
6 changes: 5 additions & 1 deletion app/test/unit/app-test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import { AheadBehindStore } from '../../src/lib/stores/ahead-behind-store'
import { AliveStore } from '../../src/lib/stores/alive-store'
import { NotificationsStore } from '../../src/lib/stores/notifications-store'
import { NotificationsDebugStore } from '../../src/lib/stores/notifications-debug-store'
import { LocalStorageManager } from '../../src/lib/local-storage'

describe('App', () => {
let appStore: AppStore
Expand Down Expand Up @@ -94,6 +95,8 @@ describe('App', () => {
pullRequestCoordinator
)

const localStorageManager = new LocalStorageManager()

appStore = new AppStore(
githubUserStore,
new CloningRepositoriesStore(),
Expand All @@ -105,7 +108,8 @@ describe('App', () => {
pullRequestCoordinator,
repositoryStateManager,
apiRepositoriesStore,
notificationsStore
notificationsStore,
localStorageManager
)

dispatcher = new InMemoryDispatcher(
Expand Down