summaryrefslogtreecommitdiff
path: root/src/fossil.page.fileedit.js
diff options
context:
space:
mode:
authorcashmere <cashmere@autistici.org>2026-09-04 09:05:24 +0200
committercashmere <cashmere@autistici.org>2026-09-04 09:05:24 +0200
commit4f49b16079b27d7ce633619e4cddfdf2bffbc952 (patch)
treec296c1e7f3c3e2f6859c6d3a1b1b208779b89679 /src/fossil.page.fileedit.js
downloadrelic-master.tar.gz
initial commitHEADmaster
Diffstat (limited to 'src/fossil.page.fileedit.js')
-rw-r--r--src/fossil.page.fileedit.js1413
1 files changed, 1413 insertions, 0 deletions
diff --git a/src/fossil.page.fileedit.js b/src/fossil.page.fileedit.js
new file mode 100644
index 0000000..42e1221
--- /dev/null
+++ b/src/fossil.page.fileedit.js
@@ -0,0 +1,1413 @@
+(function(F/*the fossil object*/){
+ "use strict";
+ /**
+ Client-side implementation of the /filepage app. Requires that
+ the fossil JS bootstrapping is complete and that several fossil
+ JS APIs have been installed: fossil.fetch, fossil.dom,
+ fossil.tabs, fossil.storage, fossil.confirmer.
+
+ Custom events which can be listened for via
+ fossil.page.addEventListener():
+
+ - Event 'fileedit-file-loaded': passes on information when it
+ loads a file (whether from the network or its internal local-edit
+ cache), in the form of an "finfo" object:
+
+ {
+ filename: string,
+ checkin: UUID string,
+ branch: branch name of UUID,
+ isExe: bool, true only for executable files
+ mimetype: mimetype string, as determined by the fossil server.
+ }
+
+ The internal docs and code frequently use the term "finfo", and such
+ references refer to an object with that form.
+
+ The fossil.page.fileContent() method gets or sets the current file
+ content for the page.
+
+ - Event 'fileedit-committed': is fired when a commit completes,
+ passing on the same info as fileedit-file-loaded.
+
+ - Event 'fileedit-content-replaced': when the editor's content is
+ replaced, as opposed to it being edited via user
+ interaction. This normally happens via selecting a file to
+ load. The event detail is the fossil.page object, not the current
+ file content.
+
+ - Event 'fileedit-preview-updated': when the preview is refreshed
+ from the server, this event passes on information about the preview
+ change in the form of an object:
+
+ {
+ element: the DOM element which contains the content preview.
+
+ mimetype: the fossil-reported content mimetype.
+
+ previewMode: a string describing the preview mode: see
+ the fossil.page.previewModes map for the values. This can
+ be used to determine whether, e.g., the content is suitable
+ for applying a 3rd-party code highlighting API to.
+ }
+
+ Here's an example which can be used with the highlightjs code
+ highlighter to update the highlighting when the preview is
+ refreshed in "wiki" mode (which includes fossil-native wiki and
+ markdown):
+
+ fossil.page.addEventListener(
+ 'fileedit-preview-updated',
+ (ev)=>{
+ if(ev.detail.previewMode==='wiki'){
+ ev.detail.element.querySelectorAll(
+ 'code[class^=language-]'
+ ).forEach((e)=>hljs.highlightBlock(e));
+ }
+ }
+ );
+ */
+ const E = (s)=>document.querySelector(s),
+ D = F.dom,
+ P = F.page;
+
+ P.config = {
+ defaultMaxStashSize: 7,
+ /**
+ See notes for this setting in fossil.page.wikiedit.js. Both
+ /wikiedit and /fileedit share this persistent config option
+ under the same storage key.
+ */
+ shiftEnterPreview: F.storage.getBool('edit-shift-enter-preview', true)
+ };
+
+ /**
+ $stash is an internal-use-only object for managing "stashed"
+ local edits, to help avoid that users accidentally lose content
+ by switching tabs or following links or some such. The basic
+ theory of operation is...
+
+ All "stashed" state is stored using fossil.storage.
+
+ - When the current file content is modified by the user, the
+ current stathe of the current P.finfo and its the content
+ is stashed. For the built-in editor widget, "changes" is
+ notified via a 'change' event. For a client-side custom
+ widget, the client needs to call P.stashContentChange() when
+ their widget triggers the equivalent of a 'change' event.
+
+ - For certain non-content updates (as of this writing, only the
+ is-executable checkbox), only the P.finfo stash entry is
+ updated, not the content (unless the content has not yet been
+ stashed, in which case it is also stashed so that the stash
+ always has matching pairs of finfo/content).
+
+ - When saving, the stashed entry for the previous version is removed
+ from the stash.
+
+ - When "loading", we use any stashed state for the given
+ checkin/file combination. When forcing a re-load of content,
+ any stashed entry for that combination is removed from the
+ stash.
+
+ - Every time P.stashContentChange() updates the stash, it is
+ pruned to $stash.prune.defaultMaxCount most-recently-updated
+ entries.
+
+ - This API often refers to "finfo objects." Those are objects
+ with a minimum of {checkin,filename} properties (which must be
+ valid), and a combination of those two properties is used as
+ basis for the stash keys for any given checkin/filename
+ combination.
+
+ The structure of the stash is a bit convoluted for efficiency's
+ sake: we store a map of file info (finfo) objects separately from
+ those files' contents because otherwise we would be required to
+ JSONize/de-JSONize the file content when stashing/restoring it,
+ and that would be horribly inefficient (meaning "battery-consuming"
+ on mobile devices).
+ */
+ const $stash = {
+ keys: {
+ index: F.page.name+'.index'
+ },
+ /**
+ index: {
+ "CHECKIN_HASH:FILENAME": {file info w/o content}
+ ...
+ }
+
+ In F.storage we...
+
+ - Store this.index under the key this.keys.index.
+
+ - Store each file's content under the key
+ (P.name+'/CHECKIN_HASH:FILENAME'). These are stored separately
+ from the index entries to avoid having to JSONize/de-JSONize
+ the content. The assumption/hope is that the browser can store
+ those records "directly," without any intermediary
+ encoding/decoding going on.
+ */
+ indexKey: function(finfo){return finfo.checkin+':'+finfo.filename},
+ /** Returns the key for storing content for the given key suffix,
+ by prepending P.name to suffix. */
+ contentKey: function(suffix){return P.name+'/'+suffix},
+ /** Returns the index object, fetching it from the stash or creating
+ it anew on the first call. */
+ getIndex: function(){
+ if(!this.index){
+ this.index = F.storage.getJSON(
+ this.keys.index, {}
+ );
+ }
+ return this.index;
+ },
+ _fireStashEvent: function(){
+ if(this._disableNextEvent) delete this._disableNextEvent;
+ else F.page.dispatchEvent('fileedit-stash-updated', this);
+ },
+ /**
+ Returns the stashed version, if any, for the given finfo object.
+ */
+ getFinfo: function(finfo){
+ const ndx = this.getIndex();
+ return ndx[this.indexKey(finfo)];
+ },
+ /** Serializes this object's index to F.storage. Returns this. */
+ storeIndex: function(){
+ if(this.index) F.storage.setJSON(this.keys.index,this.index);
+ return this;
+ },
+ /** Updates the stash record for the given finfo
+ and (optionally) content. If passed 1 arg, only
+ the finfo stash is updated, else both the finfo
+ and its contents are (re-)stashed. Returns this.
+ */
+ updateFile: function(finfo,content){
+ const ndx = this.getIndex(),
+ key = this.indexKey(finfo),
+ old = ndx[key];
+ const record = old || (ndx[key]={
+ checkin: finfo.checkin,
+ filename: finfo.filename,
+ mimetype: finfo.mimetype
+ });
+ record.isExe = !!finfo.isExe;
+ record.stashTime = new Date().getTime();
+ if(!record.branch) record.branch=finfo.branch;
+ this.storeIndex();
+ if(arguments.length>1){
+ F.storage.set(this.contentKey(key), content);
+ }
+ this._fireStashEvent();
+ return this;
+ },
+ /**
+ Returns the stashed content, if any, for the given finfo
+ object.
+ */
+ stashedContent: function(finfo){
+ return F.storage.get(this.contentKey(this.indexKey(finfo)));
+ },
+ /** Returns true if we have stashed content for the given finfo
+ record. */
+ hasStashedContent: function(finfo){
+ return F.storage.contains(this.contentKey(this.indexKey(finfo)));
+ },
+ /** Unstashes the given finfo record and its content.
+ Returns this. */
+ unstash: function(finfo){
+ const ndx = this.getIndex(),
+ key = this.indexKey(finfo);
+ delete finfo.stashTime;
+ delete ndx[key];
+ F.storage.remove(this.contentKey(key));
+ this.storeIndex();
+ this._fireStashEvent();
+ return this;
+ },
+ /**
+ Clears all $stash entries from F.storage. Returns this.
+ */
+ clear: function(){
+ const ndx = this.getIndex(),
+ self = this;
+ let count = 0;
+ Object.keys(ndx).forEach(function(k){
+ ++count;
+ const e = ndx[k];
+ delete ndx[k];
+ F.storage.remove(self.contentKey(k));
+ });
+ F.storage.remove(this.keys.index);
+ delete this.index;
+ if(count) this._fireStashEvent();
+ return this;
+ },
+ /**
+ Removes all but the maxCount most-recently-updated stash
+ entries, where maxCount defaults to this.prune.defaultMaxCount.
+ */
+ prune: function f(maxCount){
+ const ndx = this.getIndex();
+ const li = [];
+ if(!maxCount || maxCount<0) maxCount = f.defaultMaxCount;
+ Object.keys(ndx).forEach((k)=>li.push(ndx[k]));
+ li.sort((l,r)=>l.stashTime - r.stashTime);
+ let n = 0;
+ while(li.length>maxCount){
+ ++n;
+ const e = li.shift();
+ this._disableNextEvent = true;
+ this.unstash(e);
+ console.warn("Pruned oldest local file edit entry:",e);
+ }
+ if(n) this._fireStashEvent();
+ }
+ };
+ $stash.prune.defaultMaxCount = P.config.defaultMaxStashSize;
+
+ /**
+ Widget for the checkin/file selection list.
+ */
+ P.fileSelectWidget = {
+ e:{
+ container: E('#fileedit-file-selector')
+ },
+ finfo: {},
+ cache: {
+ checkins: undefined,
+ files:{},
+ branchKey: 'fileedit/uuid-branches',
+ branchNames: {}
+ },
+ /**
+ Fetches the list of leaf checkins from the server and updates
+ the UI with that list.
+ */
+ loadLeaves: function(){
+ D.append(D.clearElement(
+ this.e.ciListLabel,
+ this.e.selectCi,
+ this.e.selectFiles
+ ),"Loading leaves...");
+ D.disable(this.e.btnLoadFile, this.e.selectFiles, this.e.selectCi);
+ const self = this;
+ const onload = function(list){
+ D.append(D.clearElement(self.e.ciListLabel),
+ "Open leaves (newest first):");
+ self.cache.checkins = list;
+ D.clearElement(D.enable(self.e.selectCi));
+ let loadThisOne = P.initialFiles/*possibly injected at page-load time*/;
+ if(loadThisOne){
+ self.cache.files[loadThisOne.checkin] = loadThisOne;
+ delete P.initialFiles;
+ }
+ list.forEach(function(o,n){
+ if(!n && !loadThisOne) loadThisOne = o;
+ self.cache.branchNames[F.hashDigits(o.checkin,true)] = o.branch;
+ D.option(self.e.selectCi, o.checkin,
+ o.timestamp+' ['+o.branch+']: '
+ +F.hashDigits(o.checkin));
+ });
+ F.storage.setJSON(self.cache.branchKey, self.cache.branchNames);
+ if(loadThisOne){
+ self.e.selectCi.value = loadThisOne.checkin;
+ }
+ self.loadFiles(loadThisOne ? loadThisOne.checkin : false);
+ };
+ if(P.initialLeaves/*injected at page-load time.*/){
+ const lv = P.initialLeaves;
+ delete P.initialLeaves;
+ onload(lv);
+ }else{
+ F.fetch('fileedit/filelist',{
+ urlParams:'leaves',
+ responseType: 'json',
+ onload: onload
+ });
+ }
+ },
+ /**
+ Loads the file list for the given checkin UUID. It uses a
+ cached copy on subsequent calls for the same UUID. If passed a
+ falsy value, it instead clears and disables the file selection
+ list.
+ */
+ loadFiles: function(ciUuid){
+ delete this.finfo.filename;
+ this.finfo.checkin = ciUuid;
+ const selFiles = this.e.selectFiles;
+ if(!ciUuid){
+ D.clearElement(D.disable(selFiles, this.e.btnLoadFile));
+ return this;
+ }
+ const onload = (response)=>{
+ D.clearElement(selFiles);
+ D.append(
+ D.clearElement(this.e.fileListLabel),
+ "Editable files for ",
+ D.append(
+ D.code(), "[",
+ D.a(F.repoUrl('timeline',{
+ c: ciUuid
+ }), F.hashDigits(ciUuid)),"]"
+ ), ":"
+ );
+ this.cache.files[response.checkin] = response;
+ response.editableFiles.forEach(function(fn,n){
+ D.option(selFiles, fn);
+ });
+ if(selFiles.options.length){
+ D.enable(selFiles, this.e.btnLoadFile);
+ }
+ };
+ const got = this.cache.files[ciUuid];
+ if(got){
+ onload(got);
+ return this;
+ }
+ D.disable(selFiles,this.e.btnLoadFile);
+ D.clearElement(selFiles);
+ D.append(D.clearElement(this.e.fileListLabel),
+ "Loading files for "+F.hashDigits(ciUuid)+"...");
+ F.fetch('fileedit/filelist',{
+ urlParams:{checkin: ciUuid},
+ responseType: 'json',
+ onload
+ });
+ return this;
+ },
+
+ /**
+ If this object has ever loaded the given checkin version via
+ loadLeaves(), this returns the branch name associated with that
+ version, else returns undefined;
+ */
+ checkinBranchName: function(uuid){
+ return this.cache.branchNames[F.hashDigits(uuid,true)];
+ },
+
+ /**
+ Initializes the checkin/file selector widget. Must only be
+ called once.
+ */
+ init: function(){
+ this.cache.branchNames = F.storage.getJSON(this.cache.branchKey, {});
+ const selCi = this.e.selectCi = D.addClass(D.select(), 'flex-grow'),
+ selFiles = this.e.selectFiles
+ = D.addClass(D.select(), 'file-list'),
+ btnLoad = this.e.btnLoadFile =
+ D.addClass(D.button("Load file"), "flex-shrink"),
+ filesLabel = this.e.fileListLabel =
+ D.addClass(D.div(),'flex-shrink','file-list-label'),
+ ciLabelWrapper = D.addClass(
+ D.div(), 'flex-container','flex-row', 'flex-shrink',
+ 'stretch', 'child-gap-small'
+ ),
+ btnReload = D.addClass(
+ D.button('Reload'), 'flex-shrink'
+ ),
+ ciLabel = this.e.ciListLabel =
+ D.addClass(D.span(),'flex-shrink','checkin-list-label')
+ ;
+ D.attr(selCi, 'title',"The list of opened leaves.");
+ D.attr(selFiles, 'title',
+ "The list of editable files for the selected checkin.");
+ D.attr(btnLoad, 'title',
+ "Load the selected file into the editor.");
+ D.disable(selCi, selFiles, btnLoad);
+ D.attr(selFiles, 'size', 12);
+ D.append(
+ this.e.container,
+ ciLabel,
+ D.append(ciLabelWrapper,
+ selCi,
+ btnReload),
+ filesLabel,
+ selFiles,
+ /* Use a wrapper for btnLoad so that the button itself does not
+ stretch to fill the parent width: */
+ D.append(D.addClass(D.div(), 'flex-shrink'), btnLoad)
+ );
+ if(F.config['fileedit-glob']){
+ D.append(
+ this.e.container,
+ D.append(
+ D.span(),
+ D.append(D.code(),"fileedit-glob"),
+ " config setting = ",
+ D.append(D.code(), JSON.stringify(F.config['fileedit-glob']))
+ )
+ );
+ }
+
+ this.loadLeaves();
+ selCi.addEventListener(
+ 'change', (e)=>this.loadFiles(e.target.value), false
+ );
+ const doLoad = (e)=>{
+ this.finfo.filename = selFiles.value;
+ if(this.finfo.filename){
+ P.loadFile(this.finfo.filename, this.finfo.checkin);
+ }
+ };
+ btnLoad.addEventListener('click', doLoad, false);
+ selFiles.addEventListener('dblclick', doLoad, false);
+ btnReload.addEventListener(
+ 'click', (e)=>this.loadLeaves(), false
+ );
+ delete this.init;
+ }
+ }/*P.fileSelectWidget*/;
+
+ /**
+ Widget for listing and selecting $stash entries.
+ */
+ P.stashWidget = {
+ e:{/*DOM element(s)*/},
+ init: function(domInsertPoint/*insert widget BEFORE this element*/){
+ const wrapper = D.addClass(
+ D.attr(D.div(),'id','fileedit-stash-selector'),
+ 'input-with-label'
+ );
+ const sel = this.e.select = D.select();
+ const btnClear = this.e.btnClear = D.button("Discard Edits"),
+ btnHelp = D.append(
+ D.addClass(D.div(), "help-buttonlet"),
+ 'Locally-edited files. Timestamps are the last local edit time. ',
+ 'Only the ',P.config.defaultMaxStashSize,' most recent files ',
+ 'are retained. Saving or reloading a file removes it from this list. ',
+ D.append(D.code(),F.storage.storageImplName()),
+ ' = ',F.storage.storageHelpDescription()
+ );
+
+ D.append(wrapper, "Local edits (",
+ D.append(D.code(),
+ F.storage.storageImplName()),
+ "):",
+ btnHelp, sel, btnClear);
+ F.helpButtonlets.setup(btnHelp);
+ D.option(D.disable(sel), undefined, "(empty)");
+ F.page.addEventListener('fileedit-stash-updated',(e)=>this.updateList(e.detail));
+ F.page.addEventListener('fileedit-file-loaded',(e)=>this.updateList($stash, e.detail));
+ sel.addEventListener('change',function(e){
+ const opt = this.selectedOptions[0];
+ if(opt && opt._finfo) P.loadFile(opt._finfo);
+ });
+ if(F.storage.isTransient()){/*Warn if our storage is particularly transient...*/
+ D.append(wrapper, D.append(
+ D.addClass(D.span(),'warning'),
+ "Warning: persistent storage is not available, "+
+ "so uncomitted edits will not survive a page reload."
+ ));
+ }
+ domInsertPoint.parentNode.insertBefore(wrapper, domInsertPoint);
+ P.tabs.switchToTab(1/*DOM visibility workaround*/);
+ F.confirmer(btnClear, {
+ /* must come after insertion into the DOM for the pinSize option to work. */
+ pinSize: true,
+ confirmText: "DISCARD all local edits?",
+ onconfirm: function(e){
+ if(P.finfo){
+ const stashed = P.getStashedFinfo(P.finfo);
+ P.clearStash();
+ if(stashed) P.loadFile(/*reload after discarding edits*/);
+ }else{
+ P.clearStash();
+ }
+ },
+ ticks: F.config.confirmerButtonTicks
+ });
+ D.addClass(this.e.btnClear,'hidden' /* must not be set until after confirmer is set up!*/);
+ $stash._fireStashEvent(/*read the page-load-time stash*/);
+ P.tabs.switchToTab(0/*DOM visibility workaround*/);
+ delete this.init;
+ },
+ /**
+ Regenerates the edit selection list.
+ */
+ updateList: function f(stasher,theFinfo){
+ if(!f.compare){
+ const cmpBase = (l,r)=>l<r ? -1 : (l===r ? 0 : 1);
+ f.compare = function(l,r){
+ const cmp = cmpBase(l.filename, r.filename);
+ return cmp ? cmp : cmpBase(l.checkin, r.checkin);
+ };
+ f.rxZ = /\.\d+Z$/ /* ms and 'Z' part of date string */;
+ const pad=(x)=>(''+x).length>1 ? x : '0'+x;
+ f.timestring = function ff(d){
+ return [
+ d.getFullYear(),'-',pad(d.getMonth()+1/*sigh*/),'-',pad(d.getDate()),
+ '@',pad(d.getHours()),':',pad(d.getMinutes())
+ ].join('');
+ };
+ }
+ const index = stasher.getIndex(), ilist = [];
+ Object.keys(index).forEach((finfo)=>{
+ ilist.push(index[finfo]);
+ });
+ const self = this;
+ D.clearElement(this.e.select);
+ if(0===ilist.length){
+ D.addClass(this.e.btnClear, 'hidden');
+ D.option(D.disable(this.e.select),undefined,"No local edits");
+ return;
+ }
+ D.enable(this.e.select);
+ D.removeClass(this.e.btnClear, 'hidden');
+ D.disable(D.option(this.e.select,0,"Select a local edit..."));
+ const currentFinfo = theFinfo || P.finfo || {filename:''};
+ ilist.sort(f.compare).forEach(function(finfo,n){
+ const key = stasher.indexKey(finfo),
+ branch = finfo.branch
+ || P.fileSelectWidget.checkinBranchName(finfo.checkin)||'';
+ /* Remember that we don't know the branch name for non-leaf versions
+ which P.fileSelectWidget() has never seen/cached. */
+ const opt = D.option(
+ self.e.select, n+1/*value is (almost) irrelevant*/,
+ [F.hashDigits(finfo.checkin), ' [',branch||'?branch?','] ',
+ f.timestring(new Date(finfo.stashTime)),' ',
+ false ? finfo.filename : F.shortenFilename(finfo.filename)
+ ].join('')
+ );
+ opt._finfo = finfo;
+ if(0===f.compare(currentFinfo, finfo)){
+ D.attr(opt, 'selected', true);
+ }
+ });
+ }
+ }/*P.stashWidget*/;
+
+ /**
+ Internal workaround to select the current preview mode
+ and fire a change event if the value actually changes
+ or if forceEvent is truthy.
+ */
+ P.selectPreviewMode = function(modeValue, forceEvent){
+ const s = this.e.selectPreviewMode;
+ if(!modeValue) modeValue = s.value;
+ else if(s.value != modeValue){
+ s.value = modeValue;
+ forceEvent = true;
+ }
+ if(forceEvent){
+ // Force UI update
+ s.dispatchEvent(new Event('change',{target:s}));
+ }
+ };
+
+ /**
+ Keep track of how many in-flight AJAX requests there are so we
+ can disable input elements while any are pending. For
+ simplicity's sake we simply disable ALL OF IT while any AJAX is
+ pending, rather than disabling operation-specific UI elements,
+ which would be a huge maintenance hassle.
+
+ Noting, however, that this global on/off is not *quite*
+ pedantically correct. Pedantically speaking. If an element is
+ disabled before an XHR starts, this code "should" notice that and
+ not include it in the to-re-enable list. That would be annoying
+ to do, and becomes impossible to do properly once multiple XHRs
+ are in transit and an element is disabled seprately between two
+ of those in-transit requests (that would be an unlikely, but
+ possible, corner case). As of this writing, the only elements
+ which are ever normally programmatically toggled between
+ enabled/disabled...
+
+ 1) Belong to the file selection list and remain disabled until
+ the list of leaves and files are loaded. i.e. they would be
+ disabled *anyway* during their own XHR requests.
+
+ 2) The stashWidget's SELECT list when no local edits are
+ stashed. Curiously, the all-or-nothing re-enabling implemented
+ here does not re-enable that particular selection list. That's
+ because of timing, though: that widget is "manually" disabled
+ when the list is empty, and that list is normally emptied in
+ conjunction with an XHR request.
+ */
+ const ajaxState = {
+ count: 0 /* in-flight F.fetch() requests */,
+ toDisable: undefined /* elements to disable during ajax activity */
+ };
+ F.fetch.beforesend = function f(){
+ if(!ajaxState.toDisable){
+ ajaxState.toDisable = document.querySelectorAll(
+ 'button, input, select, textarea'
+ );
+ }
+ if(1===++ajaxState.count){
+ D.addClass(document.body, 'waiting');
+ D.disable(ajaxState.toDisable);
+ }
+ };
+ F.fetch.aftersend = function(){
+ if(0===--ajaxState.count){
+ D.removeClass(document.body, 'waiting');
+ D.enable(ajaxState.toDisable);
+ }
+ };
+
+ F.onPageLoad(function() {
+ P.base = {tag: E('base')};
+ P.base.originalHref = P.base.tag.href;
+ P.tabs = new F.TabManager('#fileedit-tabs');
+ P.e = { /* various DOM elements we work with... */
+ taEditor: E('#fileedit-content-editor'),
+ taCommentSmall: E('#fileedit-comment'),
+ taCommentBig: E('#fileedit-comment-big'),
+ taComment: undefined/*gets set to one of taComment{Big,Small}*/,
+ ajaxContentTarget: E('#ajax-target'),
+ btnCommit: E("#fileedit-btn-commit"),
+ btnReload: E("#fileedit-tab-content button.fileedit-content-reload"),
+ selectPreviewMode: E('#select-preview-mode select'),
+ selectHtmlEmsWrap: E('#select-preview-html-ems'),
+ selectEolWrap: E('#select-eol-style'),
+ selectEol: E('#select-eol-style select[name=eol]'),
+ selectFontSizeWrap: E('#select-font-size'),
+ selectDiffWS: E('select[name=diff_ws]'),
+ cbLineNumbersWrap: E('#cb-line-numbers'),
+ cbAutoPreview: E('#cb-preview-autorefresh'),
+ previewTarget: E('#fileedit-tab-preview-wrapper'),
+ manifestTarget: E('#fileedit-manifest'),
+ diffTarget: E('#fileedit-tab-diff-wrapper'),
+ cbIsExe: E('input[type=checkbox][name=exec_bit]'),
+ cbManifest: E('input[type=checkbox][name=include_manifest]'),
+ editStatus: E('#fileedit-edit-status'),
+ tabs:{
+ content: E('#fileedit-tab-content'),
+ preview: E('#fileedit-tab-preview'),
+ diff: E('#fileedit-tab-diff'),
+ commit: E('#fileedit-tab-commit'),
+ fileSelect: E('#fileedit-tab-fileselect')
+ }
+ };
+ /* Figure out which comment editor to show by default and
+ hide the other one. By default we take the one which does
+ not have the 'hidden' CSS class. If neither do, we default
+ to single-line mode. */
+ if(D.hasClass(P.e.taCommentSmall, 'hidden')){
+ P.e.taComment = P.e.taCommentBig;
+ }else if(D.hasClass(P.e.taCommentBig,'hidden')){
+ P.e.taComment = P.e.taCommentSmall;
+ }else{
+ P.e.taComment = P.e.taCommentSmall;
+ D.addClass(P.e.taCommentBig, 'hidden');
+ }
+ D.removeClass(P.e.taComment, 'hidden');
+ P.tabs.addCustomWidget( E('#fossil-status-bar') ).addCustomWidget(P.e.editStatus);
+ let currentTab/*used for ctrl-enter switch between editor and preview*/;
+ P.tabs.addEventListener(
+ /* Set up auto-refresh of the preview tab... */
+ 'before-switch-to', function(ev){
+ currentTab = ev.detail;
+ if(ev.detail===P.e.tabs.preview){
+ P.baseHrefForFile();
+ if(P.previewNeedsUpdate && P.e.cbAutoPreview.checked) P.preview();
+ }else if(ev.detail===P.e.tabs.diff){
+ /* Work around a weird bug where the page gets wider than
+ the window when the diff tab is NOT in view and the
+ current SBS diff widget is wider than the window. When
+ the diff IS in view then CSS overflow magically reduces
+ the page size again. Weird. Maybe FF-specific. Note that
+ this weirdness happens even though P.e.diffTarget's parent
+ is hidden (and therefore P.e.diffTarget is also hidden).
+ */
+ D.removeClass(P.e.diffTarget, 'hidden');
+ }
+ }
+ );
+ P.tabs.addEventListener(
+ /* Set up auto-refresh of the preview tab... */
+ 'before-switch-from', function(ev){
+ if(ev.detail===P.e.tabs.preview){
+ P.baseHrefRestore();
+ }else if(ev.detail===P.e.tabs.diff){
+ /* See notes in the before-switch-to handler. */
+ D.addClass(P.e.diffTarget, 'hidden');
+ }
+ }
+ );
+ ////////////////////////////////////////////////////////////
+ // Trigger preview on Ctrl-Enter. This only works on the built-in
+ // editor widget, not a client-provided one.
+ P.e.taEditor.addEventListener('keydown',function(ev){
+ if(P.config.shiftEnterPreview && ev.shiftKey && 13===ev.keyCode){
+ ev.preventDefault();
+ ev.stopPropagation();
+ P.e.taEditor.blur(/*force change event, if needed*/);
+ P.tabs.switchToTab(P.e.tabs.preview);
+ if(!P.e.cbAutoPreview.checked){/* If NOT in auto-preview mode, trigger an update. */
+ P.preview();
+ }
+ return false;
+ }
+ }, false);
+ // If we're in the preview tab, have ctrl-enter switch back to the editor.
+ document.body.addEventListener('keydown',function(ev){
+ if(ev.shiftKey && 13 === ev.keyCode){
+ if(currentTab === P.e.tabs.preview){
+ ev.preventDefault();
+ ev.stopPropagation();
+ P.tabs.switchToTab(P.e.tabs.content);
+ P.e.taEditor.focus(/*doesn't work for client-supplied editor widget!
+ And it's slow as molasses for long docs, as focus()
+ forces a document reflow.*/);
+ return false;
+ }
+ }
+ }, true);
+
+ F.connectPagePreviewers(
+ P.e.tabs.preview.querySelector(
+ '#btn-preview-refresh'
+ )
+ );
+
+ const diffButtons = E('#fileedit-tab-diff-buttons');
+ diffButtons.querySelector('button.sbs').addEventListener(
+ "click",(e)=>P.diff(true), false
+ );
+ diffButtons.querySelector('button.unified').addEventListener(
+ "click",(e)=>P.diff(false), false
+ );
+ P.e.btnCommit.addEventListener(
+ "click",(e)=>P.commit(), false
+ );
+ P.tabs.switchToTab(1/*DOM visibility workaround*/);
+ F.confirmer(P.e.btnReload, {
+ pinSize: true,
+ confirmText: "Really reload, losing edits?",
+ onconfirm: (e)=>P.unstashContent().loadFile(),
+ ticks: F.config.confirmerButtonTicks
+ });
+ E('#comment-toggle').addEventListener(
+ "click",(e)=>P.toggleCommentMode(), false
+ );
+
+ P.e.taEditor.addEventListener('change', ()=>P.notifyOfChange(), false);
+ P.e.cbIsExe.addEventListener(
+ 'change', ()=>P.stashContentChange(true), false
+ );
+
+ /**
+ Cosmetic: jump through some hoops to enable/disable
+ certain preview options depending on the current
+ preview mode...
+ */
+ P.e.selectPreviewMode.addEventListener(
+ "change", function(e){
+ const mode = e.target.value,
+ name = P.previewModes[mode],
+ hide = [], unhide = [];
+ P.previewModes.current = name;
+ if('guess'===name){
+ unhide.push(P.e.cbLineNumbersWrap,
+ P.e.selectHtmlEmsWrap);
+ }else{
+ if('text'===name) unhide.push(P.e.cbLineNumbersWrap);
+ else hide.push(P.e.cbLineNumbersWrap);
+ if('htmlIframe'===name) unhide.push(P.e.selectHtmlEmsWrap);
+ else hide.push(P.e.selectHtmlEmsWrap);
+ }
+ hide.forEach((e)=>e.classList.add('hidden'));
+ unhide.forEach((e)=>e.classList.remove('hidden'));
+ }, false
+ );
+ P.selectPreviewMode(false, true);
+ const selectFontSize = E('select[name=editor_font_size]');
+ if(selectFontSize){
+ selectFontSize.addEventListener(
+ "change",function(e){
+ const ed = P.e.taEditor;
+ ed.className = ed.className.replace(
+ /\bfont-size-\d+/g, '' );
+ ed.classList.add('font-size-'+e.target.value);
+ }, false
+ );
+ selectFontSize.dispatchEvent(
+ // Force UI update
+ new Event('change',{target:selectFontSize})
+ );
+ }
+
+ P.addEventListener(
+ // Clear certain views when new content is loaded/set
+ 'fileedit-content-replaced',
+ ()=>{
+ P.previewNeedsUpdate = true;
+ D.clearElement(P.e.diffTarget, P.e.previewTarget, P.e.manifestTarget);
+ }
+ );
+ P.addEventListener(
+ // Clear certain views after a non-dry-run commit
+ 'fileedit-committed',
+ (e)=>{
+ if(!e.detail.dryRun){
+ D.clearElement(P.e.diffTarget, P.e.previewTarget);
+ }
+ }
+ );
+
+ P.fileSelectWidget.init();
+ P.stashWidget.init(
+ P.e.tabs.content.lastElementChild
+ );
+
+ const cbEditPreview = E('#edit-shift-enter-preview');
+ cbEditPreview.addEventListener('change', function(e){
+ F.storage.set('edit-shift-enter-preview',
+ P.config.shiftEnterPreview = e.target.checked);
+ }, false);
+ cbEditPreview.checked = P.config.shiftEnterPreview;
+ }/*F.onPageLoad()*/);
+
+ /**
+ Getter (if called with no args) or setter (if passed an arg) for
+ the current file content.
+
+ The setter form sets the content, dispatches a
+ 'fileedit-content-replaced' event, and returns this object.
+ */
+ P.fileContent = function f(){
+ if(0===arguments.length){
+ return f.get();
+ }else{
+ f.set(arguments[0] || '');
+ this.dispatchEvent('fileedit-content-replaced', this);
+ return this;
+ }
+ };
+ /* Default get/set impls for file content */
+ P.fileContent.get = function(){return P.e.taEditor.value};
+ P.fileContent.set = function(content){P.e.taEditor.value = content};
+
+ /**
+ For use when installing a custom editor widget. Pass it the
+ getter and setter callbacks to fetch resp. set the content of the
+ custom widget. They will be triggered via
+ P.fileContent(). Returns this object.
+ */
+ P.setContentMethods = function(getter, setter){
+ this.fileContent.get = getter;
+ this.fileContent.set = setter;
+ return this;
+ };
+
+ /**
+ Alerts the editor app that a "change" has happened in the editor.
+ When connecting 3rd-party editor widgets to this app, it is (or
+ may be) necessary to call this for any "change" events the widget
+ emits. Whether or not "change" means that there were "really"
+ edits is irrelevant.
+
+ This function may perform an arbitrary amount of work, so it
+ should not be called for every keypress within the editor
+ widget. Calling it for "blur" events is generally sufficient, and
+ calling it for each Enter keypress is generally reasonable but
+ also computationally costly.
+ */
+ P.notifyOfChange = function(){
+ P.stashContentChange();
+ };
+
+ /**
+ Removes the default editor widget (and any dependent elements)
+ from the DOM, adds the given element in its place, removes this
+ method from this object, and returns this object.
+ */
+ P.replaceEditorElement = function(newEditor){
+ P.e.taEditor.parentNode.insertBefore(newEditor, P.e.taEditor);
+ P.e.taEditor.remove();
+ P.e.selectFontSizeWrap.remove();
+ delete this.replaceEditorElement;
+ return P;
+ };
+
+ /**
+ If either of...
+
+ - P.previewModes.current==='wiki'
+
+ - P.previewModes.current==='guess' AND the currently-loaded file
+ has a mimetype of "text/x-fossil-wiki" or "text/x-markdown".
+
+ ... then this function updates the document's base.href to a
+ repo-relative /doc/{{this.finfo.checkin}}/{{directory part of
+ this.finfo.filename}}/
+
+ If neither of those conditions applies, this is a no-op.
+ */
+ P.baseHrefForFile = function f(){
+ const fn = this.finfo ? this.finfo.filename : undefined;
+ if(!fn) return this;
+ if(!f.wikiMimeTypes){
+ f.wikiMimeTypes = ["text/x-fossil-wiki", "text/x-markdown"];
+ }
+ if('wiki'===P.previewModes.current
+ || ('guess'===P.previewModes.current
+ && f.wikiMimeTypes.indexOf(this.finfo.mimetype)>=0)){
+ const a = fn.split('/');
+ a.pop();
+ this.base.tag.href = F.repoUrl(
+ 'doc/'+F.hashDigits(this.finfo.checkin)
+ +'/'+(a.length ? a.join('/')+'/' : '')
+ );
+ }
+ return this;
+ };
+
+ /**
+ Sets the document's base.href value to its page-load-time
+ setting.
+ */
+ P.baseHrefRestore = function(){
+ P.base.tag.href = P.base.originalHref;
+ };
+
+ /**
+ Toggles between single- and multi-line comment
+ mode.
+ */
+ P.toggleCommentMode = function(){
+ var s, h, c = this.e.taComment.value;
+ if(this.e.taComment === this.e.taCommentSmall){
+ s = this.e.taCommentBig;
+ h = this.e.taCommentSmall;
+ }else{
+ s = this.e.taCommentSmall;
+ h = this.e.taCommentBig;
+ /*
+ Doing (input[type=text].value = textarea.value) unfortunately
+ strips all newlines. To compensate we'll replace each EOL with
+ a space. Not ideal. If we were to instead escape them as \n,
+ and do the reverse when toggling again, then they would get
+ committed as escaped newlines if the user did not first switch
+ back to multi-line mode. We cannot blindly unescape the
+ newlines, in the off chance that the user actually enters \n
+ in the comment.
+ */
+ c = c.replace(/\r?\n/g,' ');
+ }
+ s.value = c;
+ this.e.taComment = s;
+ D.addClass(h, 'hidden');
+ D.removeClass(s, 'hidden');
+ };
+
+ /**
+ Returns true if fossil.page.finfo is set, indicating that a file
+ has been loaded, else it reports an error and returns false.
+
+ If passed a truthy value any error message about not having
+ a file loaded is suppressed.
+ */
+ const affirmHasFile = function(quiet){
+ if(!P.finfo){
+ if(!quiet) F.error("No file is loaded.");
+ }
+ return !!P.finfo;
+ };
+
+ /**
+ updateVersion() updates the filename and version in various UI
+ elements...
+
+ Returns this object.
+ */
+ P.updateVersion = function f(file,rev){
+ if(!f.eLinks){
+ f.eName = P.e.editStatus.querySelector('span.name');
+ f.eLinks = P.e.editStatus.querySelector('span.links');
+ }
+ if(1===arguments.length){/*assume object*/
+ this.finfo = arguments[0];
+ file = this.finfo.filename;
+ rev = this.finfo.checkin;
+ }else if(0===arguments.length){
+ if(affirmHasFile()){
+ file = this.finfo.filename;
+ rev = this.finfo.checkin;
+ }
+ }else{
+ this.finfo = {filename:file,checkin:rev};
+ }
+ const fi = this.finfo;
+ D.clearElement(f.eName, f.eLinks);
+ if(!fi){
+ D.append(f.eName, '(no file loaded)');
+ return this;
+ }
+ const rHuman = F.hashDigits(rev),
+ rUrl = F.hashDigits(rev,true);
+
+ //TODO? port over is-edited marker from /wikiedit
+ //var marker = getEditMarker(wi, false);
+ D.append(f.eName/*,marker*/,D.a(F.repoUrl('finfo',{name:file, m:rUrl}), file));
+
+ D.append(
+ f.eLinks,
+ D.append(D.span(), fi.mimetype||'?mimetype?'),
+ D.a(F.repoUrl('info/'+rUrl), rHuman),
+ D.a(F.repoUrl('timeline',{m:rUrl}), "timeline"),
+ D.a(F.repoUrl('annotate',{filename:file, checkin:rUrl}),'annotate'),
+ D.a(F.repoUrl('blame',{filename:file, checkin:rUrl}),'blame')
+ );
+ const purlArgs = F.encodeUrlArgs({
+ filename: this.finfo.filename,
+ checkin: rUrl
+ },false,true);
+ const purl = F.repoUrl('fileedit',purlArgs);
+ D.append( f.eLinks, D.a(purl,"editor permalink") );
+ this.setPageTitle("Edit: "+fi.filename);
+ return this;
+ };
+
+ /**
+ loadFile() loads (file,checkinVersion) and updates the relevant
+ UI elements to reflect the loaded state. If passed no arguments
+ then it re-uses the values from the currently-loaded file, reloading
+ it (emitting an error message if no file is loaded).
+
+ Returns this object, noting that the load is async. After loading
+ it triggers a 'fileedit-file-loaded' event, passing it
+ this.finfo.
+
+ If a locally-edited copy of the given file/rev is found, that
+ copy is used instead of one fetched from the server, but it is
+ still treated as a load event.
+
+ Alternate call forms:
+
+ - no arguments: re-loads from this.finfo.
+
+ - 1 argument: assumed to be an finfo-style object. Must have at
+ least {filename, checkin} properties, but need not have other
+ finfo state.
+ */
+ P.loadFile = function(file,rev){
+ if(0===arguments.length){
+ /* Reload from this.finfo */
+ if(!affirmHasFile()) return this;
+ file = this.finfo.filename;
+ rev = this.finfo.checkin;
+ }else if(1===arguments.length){
+ /* Assume finfo-like object */
+ const arg = arguments[0];
+ file = arg.filename;
+ rev = arg.checkin;
+ }
+ const self = this;
+ const onload = (r,headers)=>{
+ delete self.finfo;
+ self.updateVersion({
+ filename: file,
+ checkin: rev,
+ branch: headers['x-fileedit-checkin-branch'],
+ isExe: ('x'===headers['x-fileedit-file-perm']),
+ mimetype: headers['content-type'].split(';').shift()
+ });
+ self.tabs.switchToTab(self.e.tabs.content);
+ self.e.cbIsExe.checked = self.finfo.isExe;
+ self.fileContent(r);
+ P.previewNeedsUpdate = true;
+ self.dispatchEvent('fileedit-file-loaded', self.finfo);
+ };
+ const semiFinfo = {filename: file, checkin: rev};
+ const stashFinfo = this.getStashedFinfo(semiFinfo);
+ if(stashFinfo){ // fake a response from the stash...
+ this.finfo = stashFinfo;
+ this.e.cbIsExe.checked = !!stashFinfo.isExe;
+ onload(this.contentFromStash()||'',{
+ 'x-fileedit-file-perm': stashFinfo.isExe ? 'x' : undefined,
+ 'content-type': stashFinfo.mimetype,
+ 'x-fileedit-checkin-branch': stashFinfo.branch
+ });
+ F.message("Fetched from the local-edit storage:",
+ F.hashDigits(stashFinfo.checkin),
+ stashFinfo.filename);
+ return this;
+ }
+ F.message(
+ "Loading content..."
+ ).fetch('fileedit/content',{
+ urlParams: {
+ filename:file,
+ checkin:rev
+ },
+ responseHeaders: [
+ 'x-fileedit-file-perm',
+ 'x-fileedit-checkin-branch',
+ 'content-type'],
+ onload:(r,headers)=>{
+ onload(r,headers);
+ F.message('Loaded content for',
+ F.hashDigits(self.finfo.checkin),
+ self.finfo.filename);
+ }
+ });
+ return this;
+ };
+
+ /**
+ Fetches the page preview based on the contents and settings of
+ this page's input fields, and updates the UI with the
+ preview.
+
+ Returns this object, noting that the operation is async.
+ */
+ P.preview = function f(switchToTab){
+ if(!affirmHasFile()) return this;
+ return this._postPreview(this.fileContent(), function(c){
+ P._previewTo(c);
+ if(switchToTab) self.tabs.switchToTab(self.e.tabs.preview);
+ });
+ };
+
+ /**
+ Callback for use with F.connectPagePreviewers(). Gets passed
+ the preview content.
+ */
+ P._previewTo = function(c){
+ const target = this.e.previewTarget;
+ D.clearElement(target);
+ if('string'===typeof c) D.parseHtml(target,c);
+ if(F.pikchr){
+ F.pikchr.addSrcView(target.querySelectorAll('svg.pikchr'));
+ }
+ };
+
+ /**
+ Callback for use with F.connectPagePreviewers()
+ */
+ P._postPreview = function(content,callback){
+ if(!affirmHasFile()) return this;
+ if(!content){
+ callback(content);
+ return this;
+ }
+ const fd = new FormData();
+ fd.append('render_mode',this.e.selectPreviewMode.value);
+ fd.append('filename',this.finfo.filename);
+ fd.append('ln',E('[name=preview_ln]').checked ? 1 : 0);
+ fd.append('iframe_height', E('[name=preview_html_ems]').value);
+ fd.append('content',content || '');
+ F.message(
+ "Fetching preview..."
+ ).fetch('ajax/preview-text',{
+ payload: fd,
+ responseHeaders: 'x-ajax-render-mode',
+ onload: (r,header)=>{
+ P.selectPreviewMode(P.previewModes[header]);
+ if('wiki'===header) P.baseHrefForFile();
+ else P.baseHrefRestore();
+ callback(r);
+ F.message('Updated preview.');
+ P.previewNeedsUpdate = false;
+ P.dispatchEvent('fileedit-preview-updated',{
+ previewMode: P.previewModes.current,
+ mimetype: P.finfo.mimetype,
+ element: P.e.previewTarget
+ });
+ },
+ onerror: (e)=>{
+ F.fetch.onerror(e);
+ callback("Error fetching preview: "+e);
+ }
+ });
+ return this;
+ };
+
+ /**
+ Fetches the content diff based on the contents and settings of
+ this page's input fields, and updates the UI with the diff view.
+
+ Returns this object, noting that the operation is async.
+ */
+ P.diff = function f(sbs){
+ if(!affirmHasFile()) return this;
+ const content = this.fileContent(),
+ self = this,
+ target = this.e.diffTarget;
+ const fd = new FormData();
+ fd.append('filename',this.finfo.filename);
+ fd.append('checkin', this.finfo.checkin);
+ fd.append('sbs', sbs ? 1 : 0);
+ fd.append('content',content);
+ if(this.e.selectDiffWS) fd.append('ws',this.e.selectDiffWS.value);
+ F.message(
+ "Fetching diff..."
+ ).fetch('fileedit/diff',{
+ payload: fd,
+ onload: function(c){
+ D.parseHtml(D.clearElement(target),[
+ "<div>Diff <code>[",
+ self.finfo.checkin,
+ "]</code> &rarr; Local Edits</div>",
+ c||'No changes.'
+ ].join(''));
+ F.diff.setupDiffContextLoad();
+ if(sbs) P.tweakSbsDiffs();
+ F.message('Updated diff.');
+ self.tabs.switchToTab(self.e.tabs.diff);
+ }
+ });
+ return this;
+ };
+
+ /**
+ Performs an async commit based on the form contents and updates
+ the UI.
+
+ Returns this object.
+ */
+ P.commit = function f(){
+ if(!affirmHasFile()) return this;
+ const self = this;
+ const content = this.fileContent(),
+ target = D.clearElement(P.e.manifestTarget),
+ cbDryRun = E('[name=dry_run]'),
+ isDryRun = cbDryRun.checked,
+ filename = this.finfo.filename;
+ if(!f.onload){
+ f.onload = function(c){
+ const oldFinfo = JSON.parse(JSON.stringify(self.finfo))
+ if(c.manifest){
+ D.parseHtml(D.clearElement(target), [
+ "<h3>Manifest",
+ (c.dryRun?" (dry run)":""),
+ ": ", F.hashDigits(c.checkin),"</h3>",
+ "<pre><code class='fileedit-manifest'>",
+ c.manifest.replace(/</g,'&lt;'),
+ /* ^^^ replace() necessary or this breaks if the manifest
+ comment contains an unclosed HTML tags,
+ e.g. <script> */
+ "</code></pre>"
+ ].join(''));
+ delete c.manifest/*so we don't stash this with finfo*/;
+ }
+ const msg = [
+ 'Committed',
+ c.dryRun ? '(dry run)' : '',
+ '[', F.hashDigits(c.checkin) ,'].'
+ ];
+ if(!c.dryRun){
+ self.unstashContent(oldFinfo);
+ self.finfo = c;
+ self.e.taComment.value = '';
+ self.updateVersion();
+ self.fileSelectWidget.loadLeaves();
+ }
+ self.dispatchEvent('fileedit-committed', c);
+ F.message.apply(F, msg);
+ self.tabs.switchToTab(self.e.tabs.commit);
+ };
+ }
+ const fd = new FormData();
+ fd.append('filename',filename);
+ fd.append('checkin', this.finfo.checkin);
+ fd.append('content',content);
+ fd.append('dry_run',isDryRun ? 1 : 0);
+ fd.append('eol', this.e.selectEol.value || 0);
+ /* Text fields or select lists... */
+ fd.append('comment', this.e.taComment.value);
+ if(0){
+ // Comment mimetype is currently not supported by the UI...
+ ['comment_mimetype'
+ ].forEach(function(name){
+ var e = E('[name='+name+']');
+ if(e) fd.append(name,e.value);
+ });
+ }
+ /* Checkboxes: */
+ ['allow_fork',
+ 'allow_older',
+ 'exec_bit',
+ 'allow_merge_conflict',
+ 'include_manifest',
+ 'prefer_delta'
+ ].forEach(function(name){
+ var e = E('[name='+name+']');
+ if(e){
+ fd.append(name, e.checked ? 1 : 0);
+ }else{
+ console.error("Missing checkbox? name =",name);
+ }
+ });
+ F.message(
+ "Checking in..."
+ ).fetch('fileedit/commit',{
+ payload: fd,
+ responseType: 'json',
+ onload: f.onload
+ });
+ return this;
+ };
+
+ /**
+ Updates P.finfo for certain state and stashes P.finfo, with the
+ current content fetched via P.fileContent().
+
+ If passed truthy AND the stash already has stashed content for
+ the current file, only the stashed finfo record is updated, else
+ both the finfo and content are updated.
+ */
+ P.stashContentChange = function(onlyFinfo){
+ if(affirmHasFile(true)){
+ const fi = this.finfo;
+ fi.isExe = this.e.cbIsExe.checked;
+ if(!fi.branch) fi.branch = this.fileSelectWidget.checkinBranchName(fi.checkin);
+ if(onlyFinfo && $stash.hasStashedContent(fi)){
+ $stash.updateFile(fi);
+ }else{
+ $stash.updateFile(fi, P.fileContent());
+ }
+ F.message("Stashed change to",F.hashDigits(fi.checkin),fi.filename);
+ $stash.prune();
+ this.previewNeedsUpdate = true;
+ }
+ return this;
+ };
+
+ /**
+ Removes any stashed state for the current P.finfo (if set) from
+ F.storage. Returns this.
+ */
+ P.unstashContent = function(){
+ const finfo = arguments[0] || this.finfo;
+ if(finfo){
+ this.previewNeedsUpdate = true;
+ $stash.unstash(finfo);
+ //console.debug("Unstashed",finfo);
+ F.message("Unstashed",F.hashDigits(finfo.checkin),finfo.filename);
+ }
+ return this;
+ };
+
+ /**
+ Clears all stashed file state from F.storage. Returns this.
+ */
+ P.clearStash = function(){
+ $stash.clear();
+ return this;
+ };
+
+ /**
+ If stashed content for P.finfo exists, it is returned, else
+ undefined is returned.
+ */
+ P.contentFromStash = function(){
+ return affirmHasFile(true) ? $stash.stashedContent(this.finfo) : undefined;
+ };
+
+ /**
+ If a stashed version of the given finfo object exists (same
+ filename/checkin values), return it, else return undefined.
+ */
+ P.getStashedFinfo = function(finfo){
+ return $stash.getFinfo(finfo);
+ };
+
+ P.$stash = $stash /*only for testing/debugging - not part of the API.*/;
+
+})(window.fossil);