feat: add property customization to save dialogs (#19672)
This commit is contained in:
		
					parent
					
						
							
								e1824c00a9
							
						
					
				
			
			
				commit
				
					
						28466a39d8
					
				
			
		
					 8 changed files with 119 additions and 50 deletions
				
			
		|  | @ -171,6 +171,13 @@ dialog.showOpenDialog(mainWindow, { | |||
|     displayed in front of the filename text field. | ||||
|   * `showsTagField` Boolean (optional) _macOS_ - Show the tags input box, | ||||
|     defaults to `true`. | ||||
|   * `properties` String[] (optional) | ||||
|     * `showHiddenFiles` - Show hidden files in dialog. | ||||
|     * `createDirectory` _macOS_ - Allow creating new directories from dialog. | ||||
|     * `treatPackageAsDirectory` _macOS_ - Treat packages, such as `.app` folders, | ||||
|       as a directory instead of a file. | ||||
|     * `showOverwriteConfirmation` _Linux_ - Sets whether the user will be presented a confirmation dialog if the user types a file name that already exists. | ||||
|     * `dontAddToRecent` _Windows_ - Do not add the item being saved to the recent documents list. | ||||
|   * `securityScopedBookmarks` Boolean (optional) _macOS_ _mas_ - Create a [security scoped bookmark](https://developer.apple.com/library/content/documentation/Security/Conceptual/AppSandboxDesignGuide/AppSandboxInDepth/AppSandboxInDepth.html#//apple_ref/doc/uid/TP40011183-CH3-SW16) when packaged for the Mac App Store. If this option is enabled and the file doesn't already exist a blank file will be created at the chosen path. | ||||
| 
 | ||||
| Returns `String | undefined`, the path of the file chosen by the user; if the dialog is cancelled it returns `undefined`. | ||||
|  | @ -193,8 +200,14 @@ The `filters` specifies an array of file types that can be displayed, see | |||
|   * `message` String (optional) _macOS_ - Message to display above text fields. | ||||
|   * `nameFieldLabel` String (optional) _macOS_ - Custom label for the text | ||||
|     displayed in front of the filename text field. | ||||
|   * `showsTagField` Boolean (optional) _macOS_ - Show the tags input box, | ||||
|     defaults to `true`. | ||||
|   * `showsTagField` Boolean (optional) _macOS_ - Show the tags input box, defaults to `true`. | ||||
|   * `properties` String[] (optional) | ||||
|     * `showHiddenFiles` - Show hidden files in dialog. | ||||
|     * `createDirectory` _macOS_ - Allow creating new directories from dialog. | ||||
|     * `treatPackageAsDirectory` _macOS_ - Treat packages, such as `.app` folders, | ||||
|       as a directory instead of a file. | ||||
|     * `showOverwriteConfirmation` _Linux_ - Sets whether the user will be presented a confirmation dialog if the user types a file name that already exists. | ||||
|     * `dontAddToRecent` _Windows_ - Do not add the item being saved to the recent documents list. | ||||
|   * `securityScopedBookmarks` Boolean (optional) _macOS_ _mas_ - Create a [security scoped bookmark](https://developer.apple.com/library/content/documentation/Security/Conceptual/AppSandboxDesignGuide/AppSandboxInDepth/AppSandboxInDepth.html#//apple_ref/doc/uid/TP40011183-CH3-SW16) when packaged for the Mac App Store. If this option is enabled and the file doesn't already exist a blank file will be created at the chosen path. | ||||
| 
 | ||||
| Returns `Promise<Object>` - Resolve with an object containing the following: | ||||
|  |  | |||
|  | @ -4,7 +4,20 @@ const { app, BrowserWindow, deprecate } = require('electron') | |||
| const binding = process.electronBinding('dialog') | ||||
| const v8Util = process.electronBinding('v8_util') | ||||
| 
 | ||||
| const fileDialogProperties = { | ||||
| const DialogType = { | ||||
|   OPEN: 'OPEN', | ||||
|   SAVE: 'SAVE' | ||||
| } | ||||
| 
 | ||||
| const saveFileDialogProperties = { | ||||
|   createDirectory: 1 << 0, | ||||
|   showHiddenFiles: 1 << 1, | ||||
|   treatPackageAsDirectory: 1 << 2, | ||||
|   showOverwriteConfirmation: 1 << 3, | ||||
|   dontAddToRecent: 1 << 4 | ||||
| } | ||||
| 
 | ||||
| const openFileDialogProperties = { | ||||
|   openFile: 1 << 0, | ||||
|   openDirectory: 1 << 1, | ||||
|   multiSelections: 1 << 2, | ||||
|  | @ -45,6 +58,16 @@ const checkAppInitialized = function () { | |||
|   } | ||||
| } | ||||
| 
 | ||||
| const setupDialogProperties = (type, properties) => { | ||||
|   const dialogPropertiesTypes = (type === DialogType.OPEN) ? openFileDialogProperties : saveFileDialogProperties | ||||
|   let dialogProperties = 0 | ||||
|   for (const prop in dialogPropertiesTypes) { | ||||
|     if (properties.includes(prop)) { | ||||
|       dialogProperties |= dialogPropertiesTypes[prop] | ||||
|     } | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| const saveDialog = (sync, window, options) => { | ||||
|   checkAppInitialized() | ||||
| 
 | ||||
|  | @ -59,6 +82,7 @@ const saveDialog = (sync, window, options) => { | |||
|     buttonLabel = '', | ||||
|     defaultPath = '', | ||||
|     filters = [], | ||||
|     properties = [], | ||||
|     title = '', | ||||
|     message = '', | ||||
|     securityScopedBookmarks = false, | ||||
|  | @ -73,6 +97,8 @@ const saveDialog = (sync, window, options) => { | |||
|   if (typeof nameFieldLabel !== 'string') throw new TypeError('Name field label must be a string') | ||||
| 
 | ||||
|   const settings = { buttonLabel, defaultPath, filters, title, message, securityScopedBookmarks, nameFieldLabel, showsTagField, window } | ||||
|   settings.properties = setupDialogProperties(DialogType.SAVE, properties) | ||||
| 
 | ||||
|   return (sync) ? binding.showSaveDialogSync(settings) : binding.showSaveDialog(settings) | ||||
| } | ||||
| 
 | ||||
|  | @ -103,20 +129,13 @@ const openDialog = (sync, window, options) => { | |||
| 
 | ||||
|   if (!Array.isArray(properties)) throw new TypeError('Properties must be an array') | ||||
| 
 | ||||
|   let dialogProperties = 0 | ||||
|   for (const prop in fileDialogProperties) { | ||||
|     if (properties.includes(prop)) { | ||||
|       dialogProperties |= fileDialogProperties[prop] | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   if (typeof title !== 'string') throw new TypeError('Title must be a string') | ||||
|   if (typeof buttonLabel !== 'string') throw new TypeError('Button label must be a string') | ||||
|   if (typeof defaultPath !== 'string') throw new TypeError('Default path must be a string') | ||||
|   if (typeof message !== 'string') throw new TypeError('Message must be a string') | ||||
| 
 | ||||
|   const settings = { title, buttonLabel, defaultPath, filters, message, securityScopedBookmarks, window } | ||||
|   settings.properties = dialogProperties | ||||
|   settings.properties = setupDialogProperties(DialogType.OPEN, properties) | ||||
| 
 | ||||
|   return (sync) ? binding.showOpenDialogSync(settings) : binding.showOpenDialog(settings) | ||||
| } | ||||
|  |  | |||
|  | @ -455,7 +455,7 @@ void CommonWebContentsDelegate::DevToolsAddFileSystem( | |||
|     file_dialog::DialogSettings settings; | ||||
|     settings.parent_window = owner_window(); | ||||
|     settings.force_detached = offscreen_; | ||||
|     settings.properties = file_dialog::FILE_DIALOG_OPEN_DIRECTORY; | ||||
|     settings.properties = file_dialog::OPEN_DIALOG_OPEN_DIRECTORY; | ||||
|     if (!file_dialog::ShowOpenDialogSync(settings, &paths)) | ||||
|       return; | ||||
| 
 | ||||
|  |  | |||
|  | @ -24,18 +24,26 @@ namespace file_dialog { | |||
| typedef std::pair<std::string, std::vector<std::string>> Filter; | ||||
| typedef std::vector<Filter> Filters; | ||||
| 
 | ||||
| enum FileDialogProperty { | ||||
|   FILE_DIALOG_OPEN_FILE = 1 << 0, | ||||
|   FILE_DIALOG_OPEN_DIRECTORY = 1 << 1, | ||||
|   FILE_DIALOG_MULTI_SELECTIONS = 1 << 2, | ||||
|   FILE_DIALOG_CREATE_DIRECTORY = 1 << 3,  // macOS
 | ||||
|   FILE_DIALOG_SHOW_HIDDEN_FILES = 1 << 4, | ||||
|   FILE_DIALOG_PROMPT_TO_CREATE = 1 << 5,                // Windows
 | ||||
|   FILE_DIALOG_NO_RESOLVE_ALIASES = 1 << 6,              // macOS
 | ||||
|   FILE_DIALOG_TREAT_PACKAGE_APP_AS_DIRECTORY = 1 << 7,  // macOS
 | ||||
| enum OpenFileDialogProperty { | ||||
|   OPEN_DIALOG_OPEN_FILE = 1 << 0, | ||||
|   OPEN_DIALOG_OPEN_DIRECTORY = 1 << 1, | ||||
|   OPEN_DIALOG_MULTI_SELECTIONS = 1 << 2, | ||||
|   OPEN_DIALOG_CREATE_DIRECTORY = 1 << 3,  // macOS
 | ||||
|   OPEN_DIALOG_SHOW_HIDDEN_FILES = 1 << 4, | ||||
|   OPEN_DIALOG_PROMPT_TO_CREATE = 1 << 5,                // Windows
 | ||||
|   OPEN_DIALOG_NO_RESOLVE_ALIASES = 1 << 6,              // macOS
 | ||||
|   OPEN_DIALOG_TREAT_PACKAGE_APP_AS_DIRECTORY = 1 << 7,  // macOS
 | ||||
|   FILE_DIALOG_DONT_ADD_TO_RECENT = 1 << 8,              // Windows
 | ||||
| }; | ||||
| 
 | ||||
| enum SaveFileDialogProperty { | ||||
|   SAVE_DIALOG_CREATE_DIRECTORY = 1 << 0, | ||||
|   SAVE_DIALOG_SHOW_HIDDEN_FILES = 1 << 1, | ||||
|   SAVE_DIALOG_TREAT_PACKAGE_APP_AS_DIRECTORY = 1 << 2,  // macOS
 | ||||
|   SAVE_DIALOG_SHOW_OVERWRITE_CONFIRMATION = 1 << 3,     // Linux
 | ||||
|   SAVE_DIALOG_DONT_ADD_TO_RECENT = 1 << 4,              // Windows
 | ||||
| }; | ||||
| 
 | ||||
| struct DialogSettings { | ||||
|   electron::NativeWindow* parent_window = nullptr; | ||||
|   std::string title; | ||||
|  |  | |||
|  | @ -103,15 +103,26 @@ class FileChooserDialog { | |||
|       parent_->SetEnabled(true); | ||||
|   } | ||||
| 
 | ||||
|   void SetupProperties(int properties) { | ||||
|     const auto hasProp = [properties](FileDialogProperty prop) { | ||||
|   void SetupOpenProperties(int properties) { | ||||
|     const auto hasProp = [properties](OpenFileDialogProperty prop) { | ||||
|       return gboolean((properties & prop) != 0); | ||||
|     }; | ||||
|     auto* file_chooser = GTK_FILE_CHOOSER(dialog()); | ||||
|     gtk_file_chooser_set_select_multiple(file_chooser, | ||||
|                                          hasProp(FILE_DIALOG_MULTI_SELECTIONS)); | ||||
|                                          hasProp(OPEN_DIALOG_MULTI_SELECTIONS)); | ||||
|     gtk_file_chooser_set_show_hidden(file_chooser, | ||||
|                                      hasProp(FILE_DIALOG_SHOW_HIDDEN_FILES)); | ||||
|                                      hasProp(OPEN_DIALOG_SHOW_HIDDEN_FILES)); | ||||
|   } | ||||
| 
 | ||||
|   void SetupSaveProperties(int properties) { | ||||
|     const auto hasProp = [properties](SaveFileDialogProperty prop) { | ||||
|       return gboolean((properties & prop) != 0); | ||||
|     }; | ||||
|     auto* file_chooser = GTK_FILE_CHOOSER(dialog()); | ||||
|     gtk_file_chooser_set_show_hidden(file_chooser, | ||||
|                                      hasProp(SAVE_DIALOG_SHOW_HIDDEN_FILES)); | ||||
|     gtk_file_chooser_set_do_overwrite_confirmation( | ||||
|         file_chooser, hasProp(SAVE_DIALOG_SHOW_OVERWRITE_CONFIRMATION)); | ||||
|   } | ||||
| 
 | ||||
|   void RunAsynchronous() { | ||||
|  | @ -267,10 +278,10 @@ void FileChooserDialog::OnUpdatePreview(GtkWidget* chooser) { | |||
| bool ShowOpenDialogSync(const DialogSettings& settings, | ||||
|                         std::vector<base::FilePath>* paths) { | ||||
|   GtkFileChooserAction action = GTK_FILE_CHOOSER_ACTION_OPEN; | ||||
|   if (settings.properties & FILE_DIALOG_OPEN_DIRECTORY) | ||||
|   if (settings.properties & OPEN_DIALOG_OPEN_DIRECTORY) | ||||
|     action = GTK_FILE_CHOOSER_ACTION_SELECT_FOLDER; | ||||
|   FileChooserDialog open_dialog(action, settings); | ||||
|   open_dialog.SetupProperties(settings.properties); | ||||
|   open_dialog.SetupOpenProperties(settings.properties); | ||||
| 
 | ||||
|   gtk_widget_show_all(open_dialog.dialog()); | ||||
|   int response = gtk_dialog_run(GTK_DIALOG(open_dialog.dialog())); | ||||
|  | @ -284,15 +295,17 @@ bool ShowOpenDialogSync(const DialogSettings& settings, | |||
| void ShowOpenDialog(const DialogSettings& settings, | ||||
|                     electron::util::Promise promise) { | ||||
|   GtkFileChooserAction action = GTK_FILE_CHOOSER_ACTION_OPEN; | ||||
|   if (settings.properties & FILE_DIALOG_OPEN_DIRECTORY) | ||||
|   if (settings.properties & OPEN_DIALOG_OPEN_DIRECTORY) | ||||
|     action = GTK_FILE_CHOOSER_ACTION_SELECT_FOLDER; | ||||
|   FileChooserDialog* open_dialog = new FileChooserDialog(action, settings); | ||||
|   open_dialog->SetupProperties(settings.properties); | ||||
|   open_dialog->SetupOpenProperties(settings.properties); | ||||
|   open_dialog->RunOpenAsynchronous(std::move(promise)); | ||||
| } | ||||
| 
 | ||||
| bool ShowSaveDialogSync(const DialogSettings& settings, base::FilePath* path) { | ||||
|   FileChooserDialog save_dialog(GTK_FILE_CHOOSER_ACTION_SAVE, settings); | ||||
|   save_dialog.SetupSaveProperties(settings.properties); | ||||
| 
 | ||||
|   gtk_widget_show_all(save_dialog.dialog()); | ||||
|   int response = gtk_dialog_run(GTK_DIALOG(save_dialog.dialog())); | ||||
|   if (response == GTK_RESPONSE_ACCEPT) { | ||||
|  |  | |||
|  | @ -191,19 +191,28 @@ void SetupDialog(NSSavePanel* dialog, const DialogSettings& settings) { | |||
|     [dialog setNameFieldStringValue:default_filename]; | ||||
| } | ||||
| 
 | ||||
| void SetupDialogForProperties(NSOpenPanel* dialog, int properties) { | ||||
|   [dialog setCanChooseFiles:(properties & FILE_DIALOG_OPEN_FILE)]; | ||||
|   if (properties & FILE_DIALOG_OPEN_DIRECTORY) | ||||
| void SetupOpenDialogForProperties(NSOpenPanel* dialog, int properties) { | ||||
|   [dialog setCanChooseFiles:(properties & OPEN_DIALOG_OPEN_FILE)]; | ||||
|   if (properties & OPEN_DIALOG_OPEN_DIRECTORY) | ||||
|     [dialog setCanChooseDirectories:YES]; | ||||
|   if (properties & FILE_DIALOG_CREATE_DIRECTORY) | ||||
|   if (properties & OPEN_DIALOG_CREATE_DIRECTORY) | ||||
|     [dialog setCanCreateDirectories:YES]; | ||||
|   if (properties & FILE_DIALOG_MULTI_SELECTIONS) | ||||
|   if (properties & OPEN_DIALOG_MULTI_SELECTIONS) | ||||
|     [dialog setAllowsMultipleSelection:YES]; | ||||
|   if (properties & FILE_DIALOG_SHOW_HIDDEN_FILES) | ||||
|   if (properties & OPEN_DIALOG_SHOW_HIDDEN_FILES) | ||||
|     [dialog setShowsHiddenFiles:YES]; | ||||
|   if (properties & FILE_DIALOG_NO_RESOLVE_ALIASES) | ||||
|   if (properties & OPEN_DIALOG_NO_RESOLVE_ALIASES) | ||||
|     [dialog setResolvesAliases:NO]; | ||||
|   if (properties & FILE_DIALOG_TREAT_PACKAGE_APP_AS_DIRECTORY) | ||||
|   if (properties & OPEN_DIALOG_TREAT_PACKAGE_APP_AS_DIRECTORY) | ||||
|     [dialog setTreatsFilePackagesAsDirectories:YES]; | ||||
| } | ||||
| 
 | ||||
| void SetupSaveDialogForProperties(NSSavePanel* dialog, int properties) { | ||||
|   if (properties & SAVE_DIALOG_CREATE_DIRECTORY) | ||||
|     [dialog setCanCreateDirectories:YES]; | ||||
|   if (properties & SAVE_DIALOG_SHOW_HIDDEN_FILES) | ||||
|     [dialog setShowsHiddenFiles:YES]; | ||||
|   if (properties & SAVE_DIALOG_TREAT_PACKAGE_APP_AS_DIRECTORY) | ||||
|     [dialog setTreatsFilePackagesAsDirectories:YES]; | ||||
| } | ||||
| 
 | ||||
|  | @ -278,7 +287,7 @@ bool ShowOpenDialogSync(const DialogSettings& settings, | |||
|   NSOpenPanel* dialog = [NSOpenPanel openPanel]; | ||||
| 
 | ||||
|   SetupDialog(dialog, settings); | ||||
|   SetupDialogForProperties(dialog, settings.properties); | ||||
|   SetupOpenDialogForProperties(dialog, settings.properties); | ||||
| 
 | ||||
|   int chosen = RunModalDialog(dialog, settings); | ||||
|   if (chosen == NSFileHandlingPanelCancelButton) | ||||
|  | @ -324,7 +333,7 @@ void ShowOpenDialog(const DialogSettings& settings, | |||
|   NSOpenPanel* dialog = [NSOpenPanel openPanel]; | ||||
| 
 | ||||
|   SetupDialog(dialog, settings); | ||||
|   SetupDialogForProperties(dialog, settings.properties); | ||||
|   SetupOpenDialogForProperties(dialog, settings.properties); | ||||
| 
 | ||||
|   // Capture the value of the security_scoped_bookmarks settings flag | ||||
|   // and pass it to the completion handler. | ||||
|  | @ -355,6 +364,7 @@ bool ShowSaveDialogSync(const DialogSettings& settings, base::FilePath* path) { | |||
|   NSSavePanel* dialog = [NSSavePanel savePanel]; | ||||
| 
 | ||||
|   SetupDialog(dialog, settings); | ||||
|   SetupSaveDialogForProperties(dialog, settings.properties); | ||||
| 
 | ||||
|   int chosen = RunModalDialog(dialog, settings); | ||||
|   if (chosen == NSFileHandlingPanelCancelButton || ![[dialog URL] isFileURL]) | ||||
|  | @ -395,6 +405,7 @@ void ShowSaveDialog(const DialogSettings& settings, | |||
|   NSSavePanel* dialog = [NSSavePanel savePanel]; | ||||
| 
 | ||||
|   SetupDialog(dialog, settings); | ||||
|   SetupSaveDialogForProperties(dialog, settings.properties); | ||||
|   [dialog setCanSelectHiddenExtension:YES]; | ||||
| 
 | ||||
|   // Capture the value of the security_scoped_bookmarks settings flag | ||||
|  |  | |||
|  | @ -226,13 +226,13 @@ bool ShowOpenDialogSync(const DialogSettings& settings, | |||
|     return false; | ||||
| 
 | ||||
|   DWORD options = FOS_FORCEFILESYSTEM | FOS_FILEMUSTEXIST; | ||||
|   if (settings.properties & FILE_DIALOG_OPEN_DIRECTORY) | ||||
|   if (settings.properties & OPEN_DIALOG_OPEN_DIRECTORY) | ||||
|     options |= FOS_PICKFOLDERS; | ||||
|   if (settings.properties & FILE_DIALOG_MULTI_SELECTIONS) | ||||
|   if (settings.properties & OPEN_DIALOG_MULTI_SELECTIONS) | ||||
|     options |= FOS_ALLOWMULTISELECT; | ||||
|   if (settings.properties & FILE_DIALOG_SHOW_HIDDEN_FILES) | ||||
|   if (settings.properties & OPEN_DIALOG_SHOW_HIDDEN_FILES) | ||||
|     options |= FOS_FORCESHOWHIDDEN; | ||||
|   if (settings.properties & FILE_DIALOG_PROMPT_TO_CREATE) | ||||
|   if (settings.properties & OPEN_DIALOG_PROMPT_TO_CREATE) | ||||
|     options |= FOS_CREATEPROMPT; | ||||
|   if (settings.properties & FILE_DIALOG_DONT_ADD_TO_RECENT) | ||||
|     options |= FOS_DONTADDTORECENT; | ||||
|  | @ -294,8 +294,13 @@ bool ShowSaveDialogSync(const DialogSettings& settings, base::FilePath* path) { | |||
|   if (FAILED(hr)) | ||||
|     return false; | ||||
| 
 | ||||
|   file_save_dialog->SetOptions(FOS_FORCEFILESYSTEM | FOS_PATHMUSTEXIST | | ||||
|                                FOS_OVERWRITEPROMPT); | ||||
|   DWORD options = FOS_FORCEFILESYSTEM | FOS_PATHMUSTEXIST | FOS_OVERWRITEPROMPT; | ||||
|   if (settings.properties & SAVE_DIALOG_SHOW_HIDDEN_FILES) | ||||
|     options |= FOS_FORCESHOWHIDDEN; | ||||
|   if (settings.properties & SAVE_DIALOG_DONT_ADD_TO_RECENT) | ||||
|     options |= FOS_DONTADDTORECENT; | ||||
| 
 | ||||
|   file_save_dialog->SetOptions(options); | ||||
|   ApplySettings(file_save_dialog, settings); | ||||
|   hr = ShowFileDialog(file_save_dialog, settings); | ||||
| 
 | ||||
|  |  | |||
|  | @ -301,17 +301,17 @@ void WebDialogHelper::RunFileChooser( | |||
|     settings.default_path = params.default_file_name; | ||||
|     file_select_helper->ShowSaveDialog(settings); | ||||
|   } else { | ||||
|     int flags = file_dialog::FILE_DIALOG_CREATE_DIRECTORY; | ||||
|     int flags = file_dialog::OPEN_DIALOG_CREATE_DIRECTORY; | ||||
|     switch (params.mode) { | ||||
|       case FileChooserParams::Mode::kOpenMultiple: | ||||
|         flags |= file_dialog::FILE_DIALOG_MULTI_SELECTIONS; | ||||
|         flags |= file_dialog::OPEN_DIALOG_MULTI_SELECTIONS; | ||||
|         FALLTHROUGH; | ||||
|       case FileChooserParams::Mode::kOpen: | ||||
|         flags |= file_dialog::FILE_DIALOG_OPEN_FILE; | ||||
|         flags |= file_dialog::FILE_DIALOG_TREAT_PACKAGE_APP_AS_DIRECTORY; | ||||
|         flags |= file_dialog::OPEN_DIALOG_OPEN_FILE; | ||||
|         flags |= file_dialog::OPEN_DIALOG_TREAT_PACKAGE_APP_AS_DIRECTORY; | ||||
|         break; | ||||
|       case FileChooserParams::Mode::kUploadFolder: | ||||
|         flags |= file_dialog::FILE_DIALOG_OPEN_DIRECTORY; | ||||
|         flags |= file_dialog::OPEN_DIALOG_OPEN_DIRECTORY; | ||||
|         break; | ||||
|       default: | ||||
|         NOTREACHED(); | ||||
|  |  | |||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue
	
	 Shelley Vohr
				Shelley Vohr