mirror of
https://github.com/fadden/6502bench.git
synced 2025-02-12 00:30:48 +00:00
Auto-save, part 2
Periodically save the project to the recovery file if changes have been made and the project is "dirty". We need to handle a couple of things specially. If the user uses "Save As" to change the project name, we need to recreate the recovery file as well. If auto-save is enabled or disabled in app settings, we need to create or discard the recovery file, and possibly change the timer interval. If the project is modified, auto-saved, and then the change is un-done, the project won't be dirty, but will have a stale recovery file with a newer modification date; we handle this by simply truncating the stale recovery file. To reduce the amount of auto-saving, we don't do an initial write to the recovery file, and we reset the timer every time the user does a manual save. A user who saves diligently will always have an empty recovery file.
This commit is contained in:
parent
3d4250cbc4
commit
1b2353c259
@ -22,6 +22,7 @@ using System.Web.Script.Serialization;
|
||||
using System.Windows;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Threading;
|
||||
using Microsoft.Win32;
|
||||
|
||||
using Asm65;
|
||||
@ -53,7 +54,7 @@ namespace SourceGen {
|
||||
private string mProjectPathName;
|
||||
|
||||
/// <summary>
|
||||
/// Data backing the code list.
|
||||
/// Data backing the code list. Will be null if the project is not open.
|
||||
/// </summary>
|
||||
public LineListGen CodeLineList { get; private set; }
|
||||
|
||||
@ -241,9 +242,14 @@ namespace SourceGen {
|
||||
|
||||
#region Init and settings
|
||||
|
||||
/// <summary>
|
||||
/// Constructor, called from the main window code.
|
||||
/// </summary>
|
||||
public MainController(MainWindow win) {
|
||||
mMainWin = win;
|
||||
|
||||
CreateAutoSaveTimer();
|
||||
|
||||
ScriptManager.UseKeepAliveHack = true;
|
||||
}
|
||||
|
||||
@ -590,7 +596,7 @@ namespace SourceGen {
|
||||
mMainWin.DoShowCycleCounts =
|
||||
AppSettings.Global.GetBool(AppSettings.FMT_SHOW_CYCLE_COUNTS, false);
|
||||
|
||||
// Finally, update the display list generator with all the fancy settings.
|
||||
// Update the display list generator with all the fancy settings.
|
||||
if (CodeLineList != null) {
|
||||
// Regenerate the display list with the latest formatter config and
|
||||
// pseudo-op definition. (These are set as part of the refresh.)
|
||||
@ -598,6 +604,9 @@ namespace SourceGen {
|
||||
UndoableChange.CreateDummyChange(UndoableChange.ReanalysisScope.DisplayOnly);
|
||||
ApplyChanges(new ChangeSet(uc), false);
|
||||
}
|
||||
|
||||
// If auto-save was enabled or disabled, create or remove the recovery file.
|
||||
RefreshRecoveryFile();
|
||||
}
|
||||
|
||||
private void SetCodeLineListColorMultiplier() {
|
||||
@ -716,6 +725,208 @@ namespace SourceGen {
|
||||
#endregion Init and settings
|
||||
|
||||
|
||||
#region Auto-save
|
||||
|
||||
private string mRecoveryPathName = string.Empty;
|
||||
private Stream mRecoveryStream = null;
|
||||
|
||||
private DispatcherTimer mAutoSaveTimer = null;
|
||||
private DateTime mLastEditWhen = DateTime.Now;
|
||||
private DateTime mLastAutoSaveWhen = DateTime.Now;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Creates an interval timer that fires an event on the GUI thread.
|
||||
/// </summary>
|
||||
private void CreateAutoSaveTimer() {
|
||||
mAutoSaveTimer = new DispatcherTimer();
|
||||
mAutoSaveTimer.Tick += new EventHandler(AutoSaveTick);
|
||||
mAutoSaveTimer.Interval = TimeSpan.FromSeconds(5);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resets the auto-save timer to the configured interval. Has no effect if the timer
|
||||
/// isn't currently running.
|
||||
/// </summary>
|
||||
private void ResetAutoSaveTimer() {
|
||||
if (mAutoSaveTimer.IsEnabled) {
|
||||
// Setting the Interval resets the timer.
|
||||
mAutoSaveTimer.Interval = mAutoSaveTimer.Interval;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles the auto-save timer event.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// We're using a DispatcherTimer, which appears to execute as part of the dispatcher,
|
||||
/// not a System.Timers.Timer thread, which runs asynchronously. So not only do we not
|
||||
/// have to worry about SynchronizationObjects, it seems likely that this won't fire
|
||||
/// after the timer is disabled.
|
||||
/// </remarks>
|
||||
private void AutoSaveTick(object sender, EventArgs e) {
|
||||
try {
|
||||
if (mRecoveryStream == null) {
|
||||
Debug.WriteLine("AutoSave tick: no recovery file");
|
||||
return;
|
||||
}
|
||||
if (mLastEditWhen <= mLastAutoSaveWhen) {
|
||||
Debug.WriteLine("AutoSave tick: recovery file is current (edit at " +
|
||||
mLastEditWhen + ", auto-save at " + mLastAutoSaveWhen + ")");
|
||||
return;
|
||||
}
|
||||
if (!mProject.IsDirty) {
|
||||
// This may seem off, because of the following scenario: open a file, make a
|
||||
// single edit, wait for auto-save, then hit Undo. Changes have been made,
|
||||
// but the project is now back to its original form, so IsDirty is false. If
|
||||
// we don't auto-save now, the recovery file will have a newer modification
|
||||
// date than the project file, but will be stale.
|
||||
//
|
||||
// Technically, we don't need to update the recovery file, because the base
|
||||
// project file has the correct and complete project. There's no real need
|
||||
// for us to save another copy. If we crash, we'll have a stale recovery file
|
||||
// with a newer timestamp, but we could handle that by back-dating the file
|
||||
// timestamp or simply by truncating the recovery stream.
|
||||
//
|
||||
// The real reason for this test is that we don't want to auto-save if the
|
||||
// user is being good about manual saves.
|
||||
if (mRecoveryStream.Length != 0) {
|
||||
Debug.WriteLine("AutoSave tick: project not dirty, truncating recovery");
|
||||
mRecoveryStream.SetLength(0);
|
||||
} else {
|
||||
Debug.WriteLine("AutoSave tick: project not dirty");
|
||||
}
|
||||
mLastAutoSaveWhen = mLastEditWhen; // bump this so earlier test fires
|
||||
return;
|
||||
}
|
||||
|
||||
// The project is dirty, and we haven't auto-saved since the last change was
|
||||
// made. Serialize the project to the recovery file.
|
||||
Mouse.OverrideCursor = Cursors.Wait;
|
||||
|
||||
DateTime startWhen = DateTime.Now;
|
||||
mRecoveryStream.Position = 0;
|
||||
mRecoveryStream.SetLength(0);
|
||||
if (!ProjectFile.SerializeToStream(mProject, mRecoveryStream,
|
||||
out string errorMessage)) {
|
||||
Debug.WriteLine("AutoSave FAILED: " + errorMessage);
|
||||
}
|
||||
mRecoveryStream.Flush(); // flush is very important, timing is not; try Async?
|
||||
mLastAutoSaveWhen = DateTime.Now;
|
||||
Debug.WriteLine("AutoSave tick: recovery file updated: " + mRecoveryStream.Length +
|
||||
" bytes (" + (mLastAutoSaveWhen - startWhen).TotalMilliseconds + " ms)");
|
||||
} catch (Exception ex) {
|
||||
// Not expected, but let's not crash just because auto-save is broken.
|
||||
Debug.WriteLine("AutoSave FAILED ENTIRELY: " + ex);
|
||||
} finally {
|
||||
Mouse.OverrideCursor = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates or deletes the recovery file, based on the current app settings.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This is called when a new project is created, an existing project is opened, the
|
||||
/// app settings are updated, or Save As is used to change the project name.
|
||||
/// </remarks>
|
||||
private void RefreshRecoveryFile() {
|
||||
if (mProject == null) {
|
||||
// Project not open, nothing to do.
|
||||
return;
|
||||
}
|
||||
|
||||
int interval = AppSettings.Global.GetInt(AppSettings.PROJ_AUTO_SAVE_INTERVAL, 0);
|
||||
if (interval <= 0) {
|
||||
// We don't want a recovery file. If one exists, close it and remove it.
|
||||
if (mRecoveryStream != null) {
|
||||
Debug.WriteLine("Recovery: auto-save is disabled");
|
||||
DiscardRecoveryFile();
|
||||
Debug.Assert(string.IsNullOrEmpty(mRecoveryPathName));
|
||||
} else {
|
||||
Debug.WriteLine("Recovery: auto-save is disabled, file was not open");
|
||||
}
|
||||
mAutoSaveTimer.Stop();
|
||||
} else {
|
||||
// Configure auto-save. We need to update the interval in case it was changed
|
||||
// by an app settings update.
|
||||
mAutoSaveTimer.Interval = TimeSpan.FromSeconds(interval);
|
||||
// Force an initial auto-save (on next timer tick) if the project is dirty, in
|
||||
// case auto-save was previously disabled.
|
||||
mLastAutoSaveWhen = mLastEditWhen.AddSeconds(-1);
|
||||
|
||||
string pathName = GenerateRecoveryPathName();
|
||||
if (!string.IsNullOrEmpty(mRecoveryPathName) && pathName == mRecoveryPathName) {
|
||||
// File is open and the filename hasn't changed. Nothing to do.
|
||||
Debug.Assert(mRecoveryStream != null);
|
||||
Debug.WriteLine("Recovery: open, no changes");
|
||||
} else {
|
||||
if (mRecoveryStream != null) {
|
||||
Debug.WriteLine("Recovery: closing '" + mRecoveryPathName +
|
||||
"' in favor of '" + pathName + "'");
|
||||
DiscardRecoveryFile();
|
||||
}
|
||||
Debug.WriteLine("Recovery: opening '" + pathName + "'");
|
||||
PrepareRecoveryFile();
|
||||
}
|
||||
mAutoSaveTimer.Start();
|
||||
}
|
||||
}
|
||||
|
||||
private string GenerateRecoveryPathName() {
|
||||
if (string.IsNullOrEmpty(mProjectPathName)) {
|
||||
return string.Empty;
|
||||
} else {
|
||||
return mProjectPathName + "_rec";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates the recovery file, overwriting any existing file. If auto-save is disabled
|
||||
/// (indicated by an empty recovery file name), this does nothing.
|
||||
/// </summary>
|
||||
private void PrepareRecoveryFile() {
|
||||
Debug.Assert(mRecoveryStream == null);
|
||||
Debug.Assert(string.IsNullOrEmpty(mRecoveryPathName));
|
||||
|
||||
string pathName = GenerateRecoveryPathName();
|
||||
try {
|
||||
mRecoveryStream = new FileStream(pathName, FileMode.OpenOrCreate, FileAccess.Write);
|
||||
mRecoveryPathName = pathName;
|
||||
} catch (Exception ex) {
|
||||
MessageBox.Show(mMainWin, "Failed to create recovery file '" +
|
||||
pathName + "': " + ex.Message, "File Error",
|
||||
MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// If we have a recovery file, close and delete it. This does nothing if the recovery
|
||||
/// file is not currently open.
|
||||
/// </summary>
|
||||
private void DiscardRecoveryFile() {
|
||||
if (mRecoveryStream == null) {
|
||||
Debug.Assert(string.IsNullOrEmpty(mRecoveryPathName));
|
||||
return;
|
||||
}
|
||||
Debug.WriteLine("Recovery: discarding recovery file '" + mRecoveryPathName + "'");
|
||||
mRecoveryStream.Close();
|
||||
try {
|
||||
File.Delete(mRecoveryPathName);
|
||||
} catch (Exception ex) {
|
||||
MessageBox.Show(mMainWin, "Failed to delete recovery file '" +
|
||||
mRecoveryPathName + "': " + ex.Message, "File Error",
|
||||
MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||
// Discard our internal state.
|
||||
}
|
||||
mRecoveryStream = null;
|
||||
mRecoveryPathName = string.Empty;
|
||||
mAutoSaveTimer.Stop();
|
||||
}
|
||||
|
||||
#endregion Auto-save
|
||||
|
||||
|
||||
#region Project management
|
||||
|
||||
private bool PrepareNewProject(string dataPathName, SystemDef sysDef) {
|
||||
@ -935,6 +1146,9 @@ namespace SourceGen {
|
||||
// ListView's selection index could be referencing a line off the end.
|
||||
// (This may not be necessary with WPF, because the way highlights work changed.)
|
||||
UpdateSelectionHighlight();
|
||||
|
||||
// Bump the edit timestamp so the auto-save will run.
|
||||
mLastEditWhen = DateTime.Now;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -1046,6 +1260,9 @@ namespace SourceGen {
|
||||
|
||||
#region Main window UI event handlers
|
||||
|
||||
/// <summary>
|
||||
/// Handles creation of a new project.
|
||||
/// </summary>
|
||||
public void NewProject() {
|
||||
if (!CloseProject()) {
|
||||
return;
|
||||
@ -1076,6 +1293,7 @@ namespace SourceGen {
|
||||
if (ok) {
|
||||
FinishPrep();
|
||||
SaveProjectAs();
|
||||
RefreshRecoveryFile();
|
||||
}
|
||||
}
|
||||
|
||||
@ -1180,6 +1398,7 @@ namespace SourceGen {
|
||||
mProjectPathName = mProject.ProjectPathName = projPathName;
|
||||
mDataPathName = dataPathName;
|
||||
FinishPrep();
|
||||
RefreshRecoveryFile();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -1300,6 +1519,8 @@ namespace SourceGen {
|
||||
// Success, record the path name.
|
||||
mProjectPathName = mProject.ProjectPathName = pathName;
|
||||
|
||||
RefreshRecoveryFile();
|
||||
|
||||
// add it to the title bar
|
||||
UpdateTitle();
|
||||
return true;
|
||||
@ -1339,6 +1560,9 @@ namespace SourceGen {
|
||||
// Seems like a good time to save this off too.
|
||||
SaveAppSettings();
|
||||
|
||||
// The project file is saved, no need to auto-save for a while.
|
||||
ResetAutoSaveTimer();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@ -1410,6 +1634,7 @@ namespace SourceGen {
|
||||
}
|
||||
mDataPathName = null;
|
||||
mProjectPathName = null;
|
||||
CodeLineList = null;
|
||||
|
||||
// We may get a "selection changed" message as things are being torn down. Clear
|
||||
// these so we don't try to remove the highlight from something that doesn't exist.
|
||||
@ -1423,6 +1648,8 @@ namespace SourceGen {
|
||||
|
||||
UpdateTitle();
|
||||
|
||||
DiscardRecoveryFile();
|
||||
|
||||
// Not necessary, but it lets us check the memory monitor to see if we got
|
||||
// rid of everything.
|
||||
GC.Collect();
|
||||
|
@ -75,15 +75,6 @@ namespace SourceGen {
|
||||
public static bool SerializeToFile(DisasmProject proj, string pathName,
|
||||
out string errorMessage) {
|
||||
try {
|
||||
string serializedData = SerializableProjectFile1.SerializeProject(proj);
|
||||
if (ADD_CRLF) {
|
||||
// Add some line breaks. This looks awful, but it makes text diffs
|
||||
// much more useful.
|
||||
serializedData = TextUtil.NonQuoteReplace(serializedData, "{", "{\r\n");
|
||||
serializedData = TextUtil.NonQuoteReplace(serializedData, "},", "},\r\n");
|
||||
serializedData = TextUtil.NonQuoteReplace(serializedData, ",", ",\r\n");
|
||||
}
|
||||
|
||||
// Check to see if the project file is read-only. We want to fail early
|
||||
// so we don't leave our .TMP file sitting around -- the File.Delete() call
|
||||
// will fail if the destination is read-only.
|
||||
@ -93,14 +84,14 @@ namespace SourceGen {
|
||||
pathName));
|
||||
}
|
||||
|
||||
// The BOM is not required or recommended for UTF-8 files, but anecdotal
|
||||
// evidence suggests that it's sometimes useful. Shouldn't cause any harm
|
||||
// to have it in the project file. The explicit Encoding.UTF8 argument
|
||||
// causes it to appear -- WriteAllText normally doesn't.
|
||||
//
|
||||
// Write to a temp file, then rename over original after write has succeeded.
|
||||
string tmpPath = pathName + ".TMP";
|
||||
File.WriteAllText(tmpPath, serializedData, Encoding.UTF8);
|
||||
using (FileStream stream = new FileStream(tmpPath, FileMode.OpenOrCreate,
|
||||
FileAccess.Write, FileShare.None)) {
|
||||
if (!SerializeToStream(proj, stream, out errorMessage)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (File.Exists(pathName)) {
|
||||
File.Delete(pathName);
|
||||
}
|
||||
@ -113,6 +104,36 @@ namespace SourceGen {
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serializes the project and writes it to the specified stream.
|
||||
/// </summary>
|
||||
/// <param name="proj">Project to serialize.</param>
|
||||
/// <param name="stream">Stream to write data to. Will not be seeked or truncated, and
|
||||
/// will be left open.</param>
|
||||
/// <param name="errorMessage">Human-readable error string, or an empty string if all
|
||||
/// went well.</param>
|
||||
/// <returns>True on success.</returns>
|
||||
public static bool SerializeToStream(DisasmProject proj, Stream stream,
|
||||
out string errorMessage) {
|
||||
string serializedData = SerializableProjectFile1.SerializeProject(proj);
|
||||
if (ADD_CRLF) {
|
||||
// Add some line breaks. This looks awful, but it makes text diffs
|
||||
// much more useful.
|
||||
serializedData = TextUtil.NonQuoteReplace(serializedData, "{", "{\r\n");
|
||||
serializedData = TextUtil.NonQuoteReplace(serializedData, "},", "},\r\n");
|
||||
serializedData = TextUtil.NonQuoteReplace(serializedData, ",", ",\r\n");
|
||||
}
|
||||
|
||||
// Use UTF-8 encoding, with a byte-order mark. It's not required or recommended,
|
||||
// but it's harmless, and might help something decide that the file is UTF-8.
|
||||
using (StreamWriter sw = new StreamWriter(stream, Encoding.UTF8, 4096, true)) {
|
||||
sw.Write(serializedData);
|
||||
}
|
||||
|
||||
errorMessage = string.Empty;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the specified file and deserializes it into the project.
|
||||
///
|
||||
|
@ -268,6 +268,7 @@ namespace SourceGen.WpfGui {
|
||||
}
|
||||
private static readonly AutoSaveItem[] sAutoSaveItems = {
|
||||
new AutoSaveItem(Res.Strings.AUTO_SAVE_OFF, 0),
|
||||
//new AutoSaveItem("5 seconds!", 5), // DEBUG
|
||||
new AutoSaveItem(Res.Strings.AUTO_SAVE_1_MIN, 60),
|
||||
new AutoSaveItem(Res.Strings.AUTO_SAVE_5_MIN, 300),
|
||||
};
|
||||
|
@ -867,45 +867,49 @@ namespace SourceGen.WpfGui {
|
||||
/// StatusChanged event fires.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Sample steps to reproduce problem:
|
||||
/// 1. add or remove a note
|
||||
/// 2. hit the down-arrow key
|
||||
/// <para>Sample steps to reproduce problem:
|
||||
/// <list type="number">
|
||||
/// <item>add or remove a Note</item>
|
||||
/// <item>hit the down-arrow key</item>
|
||||
/// </list></para>
|
||||
///
|
||||
/// This causes the ListView's contents to change enough that the keyboard position
|
||||
/// <para>This causes the ListView's contents to change enough that the keyboard position
|
||||
/// is reset to zero, so attempting to move cursor up or down with an arrow key causes
|
||||
/// the ListView position to jump to the top of the file. The keyboard navigation
|
||||
/// appears to be independent of which element(s) are selected.
|
||||
/// appears to be independent of which element(s) are selected.</para>
|
||||
///
|
||||
/// The workaround for this is to set the focus to the specific item where you want the
|
||||
/// keyboard to be after making a change to the list. This isn't quite so simple,
|
||||
/// <para>The workaround for this is to set the focus to the specific item where you want
|
||||
/// the keyboard to be after making a change to the list. This isn't quite so simple,
|
||||
/// because at the point where we're restoring the selection flags, the UI elements
|
||||
/// haven't yet been generated. We need to wait for a "status changed" event to arrive
|
||||
/// from the ItemContainerGenerator.
|
||||
/// from the ItemContainerGenerator.</para>
|
||||
///
|
||||
/// This: http://cytivrat.blogspot.com/2011/05/selecting-first-item-in-wpf-listview.html
|
||||
/// formed the basis of my initial solution. The blog post was about a different problem,
|
||||
/// <para>The blog post
|
||||
/// <see href="http://cytivrat.blogspot.com/2011/05/selecting-first-item-in-wpf-listview.html"/>
|
||||
/// formed the basis of my initial solution. The post was about a different problem,
|
||||
/// where you'd have to hit the down-arrow twice after the control was first created
|
||||
/// because the focus is on the control rather than the item. The same approach applies
|
||||
/// here as well.
|
||||
/// here as well.</para>
|
||||
///
|
||||
/// Unfortunately, grabbing focus like this on every update causes problems with the
|
||||
/// <para>Unfortunately, grabbing focus like this on every update causes problems with the
|
||||
/// GridSplitters. As soon as the splitter start to move, the ListView grabs focus and
|
||||
/// prevents them from moving more than a few pixels. The workaround was to do nothing
|
||||
/// while the splitters are being moved. This didn't solve the problem completely,
|
||||
/// e.g. you couldn't move the splitters with the arrow keys by more than one step
|
||||
/// because the ListView gets a StatusChanged event and steals focus away, but at least the
|
||||
/// mouse worked. (See issue #52 and https://stackoverflow.com/q/58652064/294248.)
|
||||
/// mouse worked. (See issue #52 and
|
||||
/// <see href="https://stackoverflow.com/q/58652064/294248"/>.)</para>
|
||||
///
|
||||
/// Unfortunately, this update didn't solve other problems created by the initial solution,
|
||||
/// because setting the item focus clears multi-select. If you held shift down while
|
||||
/// hitting down-arrow, things would work fine until you reached the bottom of the
|
||||
/// screen, at which point the virtual UI stuff would cause the item container generator
|
||||
/// to do work and change state. (See issue #105.)
|
||||
/// <para>Unfortunately, this update didn't solve other problems created by the initial
|
||||
/// solution, because setting the item focus clears multi-select. If you held shift
|
||||
/// down while hitting down-arrow, things would work fine until you reached the bottom of
|
||||
/// the screen, at which point the virtual UI stuff would cause the item container
|
||||
/// generator to do work and change state. (See issue #105.)</para>
|
||||
///
|
||||
/// The current approach is to set an explicit "refocus needed" boolean when we make
|
||||
/// <para>The current approach is to set an explicit "refocus needed" boolean when we make
|
||||
/// changes to the list, and ignore the "status changed" events in other circumstances.
|
||||
/// This seems to have the correct behavior (so far).
|
||||
/// Hat tip to https://stackoverflow.com/a/53666203/294248
|
||||
/// Hat tip to <see href="https://stackoverflow.com/a/53666203/294248"/></para>
|
||||
/// </remarks>
|
||||
private void ItemContainerGenerator_StatusChanged(object sender, EventArgs e) {
|
||||
if (!mCodeViewRefocusNeeded) {
|
||||
|
Loading…
x
Reference in New Issue
Block a user